import { IconLoader2 as Loader2, IconMoon as Moon, IconSun as Sun } from '@tabler/icons-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTheme } from 'next-themes'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { ContentPanel } from '@/components/ContentPanel'; import { PageHeader } from '@/components/PageHeader'; import { Button } from '@/components/ui/button'; import { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; import { Slider } from '@/components/ui/slider'; import { Switch } from '@/components/ui/switch'; import { apiJson } from '@/lib/api'; import { TAILWIND_COLORS } from '@/lib/tailwind-colors'; import { applyTheme, DEFAULT_THEME, resetTheme, type ThemeConfig } from '@/lib/theme'; import { cn } from '@/lib/utils'; type SlotKey = | 'gradientStart' | 'gradientEnd' | 'card' | 'primary' | 'primaryDim' | 'primaryForeground' | 'secondaryAccent' | 'secondaryAccentForeground' | 'destructive' | 'warning' | 'success'; type ActiveSlot = { mode: 'light' | 'dark'; key: SlotKey; } | null; const LIGHT_SLOTS: { key: SlotKey; labelKey: string }[] = [ { key: 'gradientStart', labelKey: 'theme.colorSlots.gradientStart' }, { key: 'gradientEnd', labelKey: 'theme.colorSlots.gradientEnd' }, { key: 'card', labelKey: 'theme.colorSlots.card' }, { key: 'primary', labelKey: 'theme.colorSlots.primary' }, { key: 'primaryDim', labelKey: 'theme.colorSlots.primaryDim' }, { key: 'primaryForeground', labelKey: 'theme.colorSlots.primaryForeground' }, { key: 'secondaryAccent', labelKey: 'theme.colorSlots.secondaryAccent' }, { key: 'destructive', labelKey: 'theme.colorSlots.destructive' }, { key: 'warning', labelKey: 'theme.colorSlots.warning' }, { key: 'success', labelKey: 'theme.colorSlots.success' }, ]; const DARK_SLOTS: { key: SlotKey; labelKey: string }[] = [ { key: 'gradientStart', labelKey: 'theme.colorSlots.gradientStart' }, { key: 'gradientEnd', labelKey: 'theme.colorSlots.gradientEnd' }, { key: 'card', labelKey: 'theme.colorSlots.card' }, { key: 'primary', labelKey: 'theme.colorSlots.primary' }, { key: 'primaryDim', labelKey: 'theme.colorSlots.primaryDim' }, { key: 'primaryForeground', labelKey: 'theme.colorSlots.primaryForeground' }, { key: 'secondaryAccent', labelKey: 'theme.colorSlots.secondaryAccent' }, ]; const RADIUS_PRESETS = [ { value: '0rem', label: 'Sharp' }, { value: '0.25rem', label: '0.25rem' }, { value: '0.5rem', label: '0.5rem' }, { value: '0.625rem', label: '0.625rem (Default)' }, { value: '0.75rem', label: '0.75rem' }, { value: '1rem', label: '1rem' }, ]; const themeQueryKey = ['admin', 'theme']; function colorToHex(value: string): string { if (value.startsWith('#')) return value; // Use the browser's CSS engine to resolve oklch/etc to rgb const ctx = document.createElement('canvas').getContext('2d'); if (!ctx) return '#888888'; ctx.fillStyle = value; return ctx.fillStyle; // returns hex string like "#rrggbb" } export default function AdminTheme() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { resolvedTheme, setTheme } = useTheme(); const [localTheme, setLocalTheme] = useState(DEFAULT_THEME); const [activeSlot, setActiveSlot] = useState(null); const [hexInput, setHexInput] = useState(''); const editingMode = resolvedTheme === 'dark' ? 'dark' : 'light'; const currentSlots = editingMode === 'light' ? LIGHT_SLOTS : DARK_SLOTS; const { data: fetchedTheme, isLoading } = useQuery< { theme: Partial }, Error, ThemeConfig >({ queryKey: themeQueryKey, queryFn: () => apiJson<{ theme: Partial }>('/api/v1/admin/theme', {}, 'Failed to fetch theme'), refetchOnWindowFocus: false, select: (data) => { if (data?.theme && Object.keys(data.theme).length > 0) { return { light: { ...DEFAULT_THEME.light, ...data.theme.light }, dark: { ...DEFAULT_THEME.dark, ...data.theme.dark }, radius: data.theme.radius ?? DEFAULT_THEME.radius, smoothScrollEnabled: data.theme.smoothScrollEnabled ?? DEFAULT_THEME.smoothScrollEnabled, smoothScrollIntensity: data.theme.smoothScrollIntensity ?? DEFAULT_THEME.smoothScrollIntensity, }; } return DEFAULT_THEME; }, }); // Sync fetched theme to local state on load useEffect(() => { if (fetchedTheme) { setLocalTheme(fetchedTheme); applyTheme(fetchedTheme); } }, [fetchedTheme]); // Live preview on every local theme change useEffect(() => { applyTheme(localTheme); }, [localTheme]); // Clear active slot when switching theme mode so it doesn't reference the wrong mode // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally resets only when editingMode changes useEffect(() => { setActiveSlot(null); }, [editingMode]); // Track whether initial data has loaded to avoid auto-saving defaults const initialized = useRef(false); useEffect(() => { if (fetchedTheme) initialized.current = true; }, [fetchedTheme]); const saveMutation = useMutation({ mutationFn: (theme: ThemeConfig) => apiJson<{ theme: Partial }>( '/api/v1/admin/theme', { method: 'PATCH', body: { theme } }, 'Failed to save theme' ), onSuccess: () => { queryClient.invalidateQueries({ queryKey: themeQueryKey }); }, onError: () => { toast.error(t('theme.saveFailed')); }, }); // Auto-save with debounce after user changes // biome-ignore lint/correctness/useExhaustiveDependencies: saveMutation.mutate is stable, only re-run on localTheme changes useEffect(() => { if (!initialized.current) return; const timer = setTimeout(() => { saveMutation.mutate(localTheme); }, 500); return () => clearTimeout(timer); }, [localTheme]); const handleSlotClick = (mode: 'light' | 'dark', key: SlotKey) => { setActiveSlot({ mode, key }); const currentValue = mode === 'light' ? localTheme.light[key as keyof typeof localTheme.light] : localTheme.dark[key as keyof typeof localTheme.dark]; if (currentValue) { setHexInput(colorToHex(currentValue)); } }; const updateSlotColor = (color: string) => { if (!activeSlot) return; const { mode, key } = activeSlot; setLocalTheme((prev) => ({ ...prev, [mode]: { ...prev[mode], [key]: color, }, })); setHexInput(color.startsWith('#') ? color : colorToHex(color)); }; const handleHexChange = (hex: string) => { setHexInput(hex); // Only update theme if it's a valid hex color if (/^#[0-9a-fA-F]{6}$/.test(hex)) { updateSlotColor(hex); } }; const handleColorInputChange = (hex: string) => { setHexInput(hex); updateSlotColor(hex); }; const handleReset = () => { resetTheme(); setLocalTheme(DEFAULT_THEME); setActiveSlot(null); }; const getSlotValue = (mode: 'light' | 'dark', key: SlotKey): string => { if (mode === 'light') { return localTheme.light[key as keyof typeof localTheme.light] ?? ''; } return (localTheme.dark[key as keyof typeof localTheme.dark] as string) ?? ''; }; if (isLoading) { return ( <>
); } return ( <>
{/* Left Column - Color Slots */}
{/* Colors - combined light/dark with theme toggle */} {editingMode === 'light' ? t('theme.lightMode') : t('theme.darkMode')} {currentSlots.map(({ key, labelKey }) => { const value = getSlotValue(editingMode, key); const isActive = activeSlot?.mode === editingMode && activeSlot?.key === key; return ( ); })} {/* Border Radius */} {t('theme.borderRadius')} {/* Smooth Scrolling */} {t('theme.smoothScrolling')} { setLocalTheme((prev) => ({ ...prev, smoothScrollEnabled: checked, })); }} />
{t('theme.smoothScrollIntensity')} {t('theme.smoothScrollDuration', { value: ((localTheme.smoothScrollIntensity / 100) * 2.0).toFixed(1), })}
{ if (Array.isArray(value)) { setLocalTheme((prev) => ({ ...prev, smoothScrollIntensity: value[0], })); } }} min={0} max={100} step={5} disabled={!localTheme.smoothScrollEnabled} />
{/* Reset Button */}
{/* Right Column - Color Picker */}
{activeSlot ? ( {t( (activeSlot.mode === 'light' ? LIGHT_SLOTS : DARK_SLOTS).find( (s) => s.key === activeSlot.key )?.labelKey ?? '' )}{' '} ({activeSlot.mode === 'light' ? t('theme.lightMode') : t('theme.darkMode')}) {/* Color swatch + hex input + native picker */}
handleColorInputChange(e.target.value)} className="absolute inset-0 size-full cursor-pointer opacity-0" />
handleHexChange(e.target.value)} placeholder="#000000" className="font-mono text-sm" />
{/* Tailwind Colors Grid */}

Tailwind Colors

{TAILWIND_COLORS.map((family) => (
{family.name}
{family.shades.map((shade) => { const isSelected = hexInput.toLowerCase() === shade.hex.toLowerCase(); return (
))}
) : (

Select a color slot on the left to start customizing.

)}
); }