Files
webmail/components/settings/themes-settings.tsx
T

393 lines
16 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useThemeStore } from '@/stores/theme-store';
import { SettingsSection } from './settings-section';
import { cn } from '@/lib/utils';
import { Check, Lock, ChevronDown, ChevronUp } from '@/components/icons';
import { Button } from '@/components/ui/button';
/** Cards shown before "Show all": one row of the grid. */
const COLLAPSED_THEME_COUNT = 3;
import { toast } from '@/stores/toast-store';
import { usePolicyStore } from '@/stores/policy-store';
/**
* The theme gallery. On its own it is a section; `embedded` draws only the
* grid (with its lock notice) inside the Appearance page's Theme section,
* right under the light/dark choice.
*/
export function ThemesSettings({ embedded }: { embedded?: boolean } = {}) {
const t = useTranslations('settings.themes');
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)
);
// One row of cards until "Show all": the gallery is long and most people
// never change the theme. The active theme always stays in that row.
const [expanded, setExpanded] = useState(false);
const cards: (typeof visibleThemes[number] | null)[] = [null, ...visibleThemes];
const activeIndex = Math.max(0, cards.findIndex((theme) => (theme?.id ?? null) === activeThemeId));
const collapsed = !expanded && cards.length > COLLAPSED_THEME_COUNT;
const shownCards = collapsed
? (activeIndex < COLLAPSED_THEME_COUNT
? cards.slice(0, COLLAPSED_THEME_COUNT)
: [...cards.slice(0, COLLAPSED_THEME_COUNT - 1), cards[activeIndex]])
: cards;
// A search result for a hidden theme asks the gallery to open first.
useEffect(() => {
const handler = (e: Event) => {
const label = (e as CustomEvent<{ label?: string }>).detail?.label;
if (label && visibleThemes.some((theme) => theme.name === label)) setExpanded(true);
};
window.addEventListener('settings-reveal', handler);
return () => window.removeEventListener('settings-reveal', handler);
}, [visibleThemes]);
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(t('forced', { name: forcedTheme?.name ?? t('admin_theme') }));
return;
}
activateTheme(id);
toast.success(id ? t('activated') : t('default_restored'));
};
const grid = (
<>
{forcedThemeId && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
{t('locked')}
</div>
)}
{/* Theme Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{shownCards.map((theme) => {
if (!theme) {
return (
<ThemeCard
key="default"
name={t('default_name')}
author="Bulwark"
isDefaultTheme
variants={['light', 'dark']}
isDark={isDark}
isActive={activeThemeId === null}
isBuiltIn
isDefault={!themePolicy.defaultThemeId}
disabled={Boolean(forcedThemeId)}
onActivate={() => handleActivate(null)}
/>
);
}
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
return (
<ThemeCard
key={theme.id}
name={theme.name}
author={theme.author}
preview={theme.preview}
css={theme.css}
isDark={isDark}
isActive={activeThemeId === theme.id}
isBuiltIn={theme.builtIn}
isDefault={themePolicy.defaultThemeId === theme.id}
isForceEnabled={isForceEnabled}
disabled={Boolean(forcedThemeId) && !isForceEnabled}
variants={theme.variants}
onActivate={() => handleActivate(theme.id)}
/>
);
})}
</div>
{cards.length > COLLAPSED_THEME_COUNT && (
<Button
variant="ghost"
size="sm"
className="mt-3 text-xs"
aria-expanded={!collapsed}
data-testid="themes-show-all"
onClick={() => setExpanded((v) => !v)}
>
{collapsed ? <ChevronDown className="w-3.5 h-3.5 me-1" /> : <ChevronUp className="w-3.5 h-3.5 me-1" />}
{collapsed ? t('show_all', { count: cards.length }) : t('show_fewer')}
</Button>
)}
</>
);
if (embedded) {
return (
<div data-search-label={t('title')} className="py-3">
<div className="text-sm font-medium text-foreground">{t('title')}</div>
<p className="text-xs text-muted-foreground mt-1 mb-3">{t('description')}</p>
{grid}
</div>
);
}
return (
<SettingsSection title={t('title')} description={t('description')}>
{grid}
</SettingsSection>
);
}
// ─── 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 t = useTranslations('settings.themes');
const tVariant = useTranslations('settings.appearance.theme');
const colors = resolveThemeColors({ css, variants, isDark: !!isDark, isDefaultTheme: !!isDefaultTheme });
return (
<div data-search-label={name} className="relative">
<button
type="button"
onClick={onActivate}
disabled={disabled}
className={cn(
'flex flex-col items-center p-3 rounded-xl border-2 transition-all text-start w-full disabled:cursor-not-allowed disabled:opacity-60',
isActive
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
: 'border-border hover:border-primary/40 bg-card',
disabled && !isActive && 'hover:border-border'
)}
>
{/* Preview: marketplace image when shipped, otherwise a mini app
mockup painted from the theme's own colour tokens. */}
<div className="w-full aspect-[16/10] rounded-lg mb-2 overflow-hidden bg-muted">
{preview ? (
<img src={preview} alt={name} className="w-full h-full object-cover" />
) : (
<ThemePreview colors={colors} />
)}
</div>
{/* Info */}
<div className="w-full">
<div className="flex items-center justify-between gap-1">
<span className="text-sm font-medium text-foreground truncate">{name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{isForceEnabled && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 font-medium flex items-center gap-0.5" title={t('admin_enforced')}>
<Lock className="w-2.5 h-2.5" />
</span>
)}
{isDefault && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">{t('default_badge')}</span>
)}
{isActive && <Check className="w-4 h-4 text-primary" />}
</div>
</div>
<span className="text-xs text-muted-foreground truncate block">{author}</span>
{variants && (
<div className="flex gap-1 mt-1">
{variants.map(v => (
<span key={v} className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{tVariant(v)}
</span>
))}
</div>
)}
</div>
</button>
</div>
);
}
// ─── 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 (
<div className="w-full h-full flex overflow-hidden" style={{ backgroundColor: colors.background }}>
{/* Icon nav rail */}
<div
className="flex flex-col items-center gap-1 py-1.5 shrink-0"
style={{ width: '9%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.primary }} />
{[0, 1, 2].map((i) => (
<div key={i} className="w-1.5 h-1.5 rounded-sm" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
))}
</div>
{/* Folder sidebar */}
<div
className="flex flex-col gap-1 p-1.5 shrink-0"
style={{ width: '25%', backgroundColor: colors.sidebar, borderRight: rail }}
>
<div className="flex items-center gap-1 mb-0.5">
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
<div className="h-1 rounded-full flex-1" style={{ backgroundColor: colors.foreground, opacity: 0.55 }} />
</div>
{[0.8, 0.65, 0.72, 0.5].map((w, i) => (
<div key={i} className="flex items-center gap-1">
<div className="w-1 h-1 rounded-sm shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.45 }} />
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
</div>
))}
</div>
{/* Message list */}
<div className="flex flex-col shrink-0" style={{ width: '36%', backgroundColor: colors.background, borderRight: rail }}>
{/* Selected row */}
<div className="flex items-start gap-1 p-1" style={{ backgroundColor: colors.primary }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.primaryForeground, opacity: 0.85 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: '70%', backgroundColor: colors.primaryForeground, opacity: 0.9 }} />
<div className="h-1 rounded-full" style={{ width: '90%', backgroundColor: colors.primaryForeground, opacity: 0.55 }} />
</div>
</div>
{/* Unread + read rows */}
{[0.6, 0.7, 0.55].map((w, i) => (
<div key={i} className="flex items-start gap-1 p-1" style={{ borderTop: rail }}>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colors.mutedForeground, opacity: 0.4 }} />
<div className="flex-1 flex flex-col gap-0.5">
<div className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.foreground, opacity: i === 0 ? 0.7 : 0.5 }} />
<div className="h-1 rounded-full" style={{ width: '85%', backgroundColor: colors.mutedForeground, opacity: 0.35 }} />
</div>
</div>
))}
</div>
{/* Reading pane */}
<div className="flex-1 flex flex-col gap-1 p-1.5 min-w-0">
<div className="h-1.5 rounded-full" style={{ width: '70%', backgroundColor: colors.foreground, opacity: 0.85 }} />
<div className="h-1 rounded-full mb-1" style={{ width: '40%', backgroundColor: colors.mutedForeground, opacity: 0.5 }} />
{[0.95, 0.85, 0.9, 0.6].map((w, i) => (
<div key={i} className="h-1 rounded-full" style={{ width: `${w * 100}%`, backgroundColor: colors.mutedForeground, opacity: 0.3 }} />
))}
</div>
</div>
);
}