Replace 895-line monolith with 5 focused tab components (Profile, Appearance, Social, Security, Integrations) mirroring AdminPortal's tab pattern. URL deep linking via ?tab= search param. Conditional rendering prevents unmounted tabs from firing API calls. Reviewed by senior-code-reviewer, senior-ui-designer, and security-penetration-tester agents — all findings actioned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
181 lines
6.5 KiB
TypeScript
181 lines
6.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Settings } from '@/types';
|
|
|
|
const accentColors = [
|
|
{ name: 'cyan', color: '#06b6d4' },
|
|
{ name: 'blue', color: '#3b82f6' },
|
|
{ name: 'purple', color: '#8b5cf6' },
|
|
{ name: 'orange', color: '#f97316' },
|
|
{ name: 'green', color: '#22c55e' },
|
|
{ name: 'red', color: '#ef4444' },
|
|
{ name: 'pink', color: '#ec4899' },
|
|
{ name: 'yellow', color: '#eab308' },
|
|
];
|
|
|
|
interface AppearanceTabProps {
|
|
settings: Settings | undefined;
|
|
updateSettings: (updates: Partial<Settings>) => Promise<Settings>;
|
|
isUpdating: boolean;
|
|
}
|
|
|
|
export default function AppearanceTab({ settings, updateSettings, isUpdating }: AppearanceTabProps) {
|
|
const queryClient = useQueryClient();
|
|
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
|
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
|
|
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
|
|
|
useEffect(() => {
|
|
if (settings) {
|
|
setSelectedColor(settings.accent_color);
|
|
setFirstDayOfWeek(settings.first_day_of_week);
|
|
setUpcomingDays(settings.upcoming_days);
|
|
}
|
|
}, [settings?.id]);
|
|
|
|
const handleColorChange = async (color: string) => {
|
|
setSelectedColor(color);
|
|
try {
|
|
await updateSettings({ accent_color: color });
|
|
toast.success('Accent color updated');
|
|
} catch {
|
|
toast.error('Failed to update accent color');
|
|
}
|
|
};
|
|
|
|
const handleFirstDayChange = async (value: number) => {
|
|
const previous = firstDayOfWeek;
|
|
setFirstDayOfWeek(value);
|
|
try {
|
|
await updateSettings({ first_day_of_week: value });
|
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
|
toast.success(value === 0 ? 'Week starts on Sunday' : 'Week starts on Monday');
|
|
} catch {
|
|
setFirstDayOfWeek(previous);
|
|
toast.error('Failed to update first day of week');
|
|
}
|
|
};
|
|
|
|
const handleUpcomingDaysSave = async () => {
|
|
if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return;
|
|
if (upcomingDays === settings?.upcoming_days) return;
|
|
try {
|
|
await updateSettings({ upcoming_days: upcomingDays });
|
|
toast.success('Settings updated');
|
|
} catch {
|
|
toast.error('Failed to update settings');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Accent Color */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-3">
|
|
<Label>Accent Color</Label>
|
|
<div className="flex items-center gap-2.5">
|
|
{accentColors.map((c) => (
|
|
<button
|
|
key={c.name}
|
|
type="button"
|
|
onClick={() => handleColorChange(c.name)}
|
|
aria-pressed={selectedColor === c.name}
|
|
aria-label={c.name}
|
|
className="relative group p-1.5"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'h-7 w-7 rounded-full transition-all duration-150',
|
|
selectedColor === c.name
|
|
? 'ring-2 ring-offset-2 ring-offset-background'
|
|
: 'hover:scale-110'
|
|
)}
|
|
style={{
|
|
backgroundColor: c.color,
|
|
...(selectedColor === c.name ? { '--tw-ring-color': c.color } as React.CSSProperties : {}),
|
|
}}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Calendar & Dashboard */}
|
|
<Card>
|
|
<CardContent className="pt-6 space-y-5">
|
|
<div className="space-y-2">
|
|
<Label>First Day of Week</Label>
|
|
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleFirstDayChange(0)}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
|
firstDayOfWeek === 0
|
|
? 'text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
)}
|
|
style={{
|
|
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
Sunday
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleFirstDayChange(1)}
|
|
className={cn(
|
|
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
|
firstDayOfWeek === 1
|
|
? 'text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
)}
|
|
style={{
|
|
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
Monday
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Sets which day the calendar week starts on
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
|
<div className="flex gap-3 items-center">
|
|
<Input
|
|
id="upcoming_days"
|
|
type="number"
|
|
min="1"
|
|
max="30"
|
|
value={upcomingDays}
|
|
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
|
onBlur={handleUpcomingDaysSave}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
|
|
className="w-24"
|
|
disabled={isUpdating}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">days</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
How many days ahead to show in the upcoming items widget
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|