- ProfileThemeSection: 3-option radio rows (Light/Dark/System), immediate setTheme + PUT /api/me/theme, rollback on error - ProfileNotificationMatrix: skeleton loading, empty state, single/multi-column switch grid, 400ms debounced PUT per cell
101 lines
3.2 KiB
TypeScript
101 lines
3.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useTheme } from 'next-themes';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
import { Sun, Moon, Monitor, Check } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
type ThemeValue = 'light' | 'dark' | 'system';
|
|
|
|
interface ThemeOption {
|
|
value: ThemeValue;
|
|
label: string;
|
|
Icon: React.ComponentType<{ className?: string }>;
|
|
}
|
|
|
|
const THEME_OPTIONS: ThemeOption[] = [
|
|
{ value: 'light', label: 'Light', Icon: Sun },
|
|
{ value: 'dark', label: 'Dark', Icon: Moon },
|
|
{ value: 'system', label: 'System', Icon: Monitor },
|
|
];
|
|
|
|
export function ProfileThemeSection() {
|
|
const { theme, setTheme } = useTheme();
|
|
// Track the server-canonical value separately to allow rollback on error
|
|
const [serverTheme, setServerTheme] = useState<ThemeValue>('system');
|
|
|
|
// On mount: fetch server-canonical theme and reconcile with next-themes
|
|
useEffect(() => {
|
|
fetch('/api/me/theme')
|
|
.then((r) => r.json())
|
|
.then((data: { theme: ThemeValue }) => {
|
|
setServerTheme(data.theme);
|
|
if (data.theme !== theme) {
|
|
setTheme(data.theme);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Silently fall back to current next-themes value
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
async function handleSelect(value: ThemeValue) {
|
|
const previous = (theme as ThemeValue) ?? serverTheme;
|
|
setTheme(value);
|
|
|
|
try {
|
|
const res = await fetch('/api/me/theme', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ theme: value }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
setServerTheme(value);
|
|
toast.success('Theme updated');
|
|
} catch {
|
|
setTheme(previous);
|
|
toast.error('Failed to update theme');
|
|
}
|
|
}
|
|
|
|
const currentTheme = (theme as ThemeValue) ?? 'system';
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="px-4 pt-4 pb-0">
|
|
<CardTitle className="text-xl font-semibold">Theme</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="px-4 py-4">
|
|
<div role="radiogroup" aria-label="Theme selection">
|
|
{THEME_OPTIONS.map(({ value, label, Icon }) => {
|
|
const isActive = currentTheme === value;
|
|
return (
|
|
<div
|
|
key={value}
|
|
role="radio"
|
|
aria-checked={isActive}
|
|
tabIndex={0}
|
|
onClick={() => handleSelect(value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleSelect(value);
|
|
}
|
|
}}
|
|
className={`min-h-[44px] flex items-center gap-3 px-2 py-3 rounded-md cursor-pointer hover:bg-accent transition-colors ${isActive ? 'text-primary' : ''}`}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
<span className="text-sm flex-1">{label}</span>
|
|
{isActive && <Check className="w-4 h-4 shrink-0" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|