'use client'; import { useEffect, useState } from 'react'; import { useThemeStore } from '@/stores/theme-store'; import { SettingsSection } from './settings-section'; import { cn } from '@/lib/utils'; import { Check, Lock } from 'lucide-react'; import { toast } from '@/stores/toast-store'; import { usePolicyStore } from '@/stores/policy-store'; export function ThemesSettings() { const { installedThemes, activeThemeId, activateTheme } = useThemeStore(); const { isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore(); const themePolicy = getThemePolicy(); const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id)); // Render each preview in the variant matching the app's current mode, and // keep it in sync when the user toggles light/dark elsewhere. const [isDark, setIsDark] = useState(false); useEffect(() => { const root = document.documentElement; const update = () => setIsDark(root.classList.contains('dark')); update(); const obs = new MutationObserver(update); obs.observe(root, { attributes: true, attributeFilter: ['class'] }); return () => obs.disconnect(); }, []); // Filter out themes disabled by admin policy const visibleThemes = installedThemes.filter( theme => !isThemeDisabled(theme.id, !!theme.builtIn) ); useEffect(() => { if (forcedThemeId && activeThemeId !== forcedThemeId) { activateTheme(forcedThemeId); } }, [activeThemeId, activateTheme, forcedThemeId]); // If the active theme was disabled by admin, fall back to default useEffect(() => { if (activeThemeId) { const activeTheme = installedThemes.find(t => t.id === activeThemeId); if (activeTheme && isThemeDisabled(activeThemeId, !!activeTheme.builtIn)) { activateTheme(forcedThemeId ?? null); } } }, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]); const handleActivate = (id: string | null) => { // Clicking the already-active theme is a no-op (no re-apply, no toast). if (id === activeThemeId) return; if (forcedThemeId && id !== forcedThemeId) { const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId); toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`); return; } activateTheme(id); toast.success(id ? 'Theme activated' : 'Default theme restored'); }; return ( {forcedThemeId && (
Theme selection is locked by an administrator.
)} {/* Theme Grid */}
{/* No "Default/Bulwark" card: product decision 2026-08-05 ships exactly two themes (SRC default, VNClagoon) - see DEFAULT_THEME_POLICY in lib/admin/types.ts. The underlying activateTheme(null) capability stays reachable programmatically (e.g. an admin clearing defaultThemeId), just not offered as a selectable card here. */} {visibleThemes.map(theme => { const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id); return ( handleActivate(theme.id)} /> ); })}
); } // ─── Theme Card ────────────────────────────────────────────── interface ThemeCardProps { name: string; author: string; preview?: string; css?: string; isDark?: boolean; isDefaultTheme?: boolean; isActive: boolean; isBuiltIn: boolean; isDefault?: boolean; isForceEnabled?: boolean; disabled?: boolean; variants?: ('light' | 'dark')[]; onActivate: () => void; } function ThemeCard({ name, author, preview, css, isDark, isDefaultTheme, isActive, isDefault, isForceEnabled, disabled, variants, onActivate }: ThemeCardProps) { const colors = resolveThemeColors({ css, variants, isDark: !!isDark, isDefaultTheme: !!isDefaultTheme }); return (
); } // ─── Theme Preview ─────────────────────────────────────────── interface ThemeColors { background: string; sidebar: string; card: string; primary: string; primaryForeground: string; foreground: string; mutedForeground: string; border: string; } // The built-in "Default" theme has no `css` of its own (it's the app's base // tokens); these mirror app/globals.css so its card previews accurately. const DEFAULT_LIGHT: ThemeColors = { background: '#ffffff', sidebar: '#f8fafc', card: '#ffffff', primary: '#3b82f6', primaryForeground: '#ffffff', foreground: '#0f172a', mutedForeground: '#64748b', border: '#e2e8f0', }; const DEFAULT_DARK: ThemeColors = { background: '#0a0a0a', sidebar: '#0a0a0a', card: '#141414', primary: '#fafafa', primaryForeground: '#171717', foreground: '#fafafa', mutedForeground: '#a3a3a3', border: '#262626', }; // Pull a handful of structural colour tokens out of a theme's compiled CSS. // Themes declare light tokens under `:root { … }` and dark under `.dark { … }`; // neither block nests braces, so a non-greedy capture is enough. function extractThemeColors(css: string, dark: boolean): ThemeColors | null { const block = css.match(dark ? /\.dark\s*\{([\s\S]*?)\}/ : /:root\s*\{([\s\S]*?)\}/); if (!block) return null; const body = block[1]; const get = (name: string): string | undefined => { const m = body.match(new RegExp(`--color-${name}\\s*:\\s*([^;]+);`)); return m ? m[1].trim() : undefined; }; const background = get('background'); if (!background) return null; return { background, sidebar: get('sidebar') ?? background, card: get('card') ?? background, primary: get('primary') ?? '#888888', primaryForeground: get('primary-foreground') ?? '#ffffff', foreground: get('foreground') ?? '#000000', mutedForeground: get('muted-foreground') ?? '#888888', border: get('border') ?? 'rgba(128,128,128,0.3)', }; } function resolveThemeColors({ css, variants, isDark, isDefaultTheme }: { css?: string; variants?: ('light' | 'dark')[]; isDark: boolean; isDefaultTheme: boolean; }): ThemeColors { // Prefer the variant matching the app's current mode; fall back to the one // the theme actually ships if it's single-variant. const hasLight = !variants || variants.includes('light'); const hasDark = !variants || variants.includes('dark'); const wantDark = (isDark && hasDark) || !hasLight; if (isDefaultTheme || !css) return wantDark ? DEFAULT_DARK : DEFAULT_LIGHT; return ( extractThemeColors(css, wantDark) ?? extractThemeColors(css, !wantDark) ?? (wantDark ? DEFAULT_DARK : DEFAULT_LIGHT) ); } // A miniature of the real three-pane mailbox - icon nav rail, folder sidebar, // message list (first row selected) and reading pane - painted with the // theme's own colours, standing in for a screenshot. function ThemePreview({ colors }: { colors: ThemeColors }) { const rail = `1px solid ${colors.border}`; return (
{/* Icon nav rail */}
{[0, 1, 2].map((i) => (
))}
{/* Folder sidebar */}
{[0.8, 0.65, 0.72, 0.5].map((w, i) => (
))}
{/* Message list */}
{/* Selected row */}
{/* Unread + read rows */} {[0.6, 0.7, 0.55].map((w, i) => (
))}
{/* Reading pane */}
{[0.95, 0.85, 0.9, 0.6].map((w, i) => (
))}
); }