UMBRA/frontend/src/components/settings/AppearanceTab.tsx
Kyle Pope f2050efe2d Redesign Settings page with tab-based layout
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>
2026-03-11 14:58:28 +08:00

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>
);
}