Add 9 preset themes (Midnight, Forest, Sunset, Rose, Lavender, Arctic, Nord, Dracula, Clean Light) with full palette definitions. Implement theme token encoding/decoding algorithm: - Token format: "T1:" + base64url(minified JSON) - Share any theme via copyable token string - Import themes by pasting tokens - Only 16 core color fields encoded (badge classes derived)
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
/**
|
|
* Theme switcher — preset selection + custom token editor.
|
|
*
|
|
* Presets:
|
|
* Default — Linear / Vercel dual-color (index.css :root / .dark)
|
|
* Custom — user-editable palette, stored in localStorage
|
|
*
|
|
* The panel is opened via the sidebar "Theme" button and presented as a Sheet.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
applyPaletteToDOM,
|
|
clearCustomPalette,
|
|
encodeThemeToken,
|
|
decodeThemeToken,
|
|
isThemeToken,
|
|
loadActivePresetId,
|
|
loadCustomPalette,
|
|
resetDOMFromPalette,
|
|
saveActivePresetId,
|
|
saveCustomPalette,
|
|
THEME_PRESETS,
|
|
} from './design-system';
|
|
import type { PaletteEntry, ThemePresetId } from './design-system';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
|
import { cn } from '@/lib/utils';
|
|
import { useTheme } from '@/contexts';
|
|
import { Check, Copy, Download, RotateCcw, Sliders } from 'lucide-react';
|
|
import {LanguageSwitcher} from '@/components/shared/LanguageSwitcher';
|
|
|
|
// ─── Token definitions ───────────────────────────────────────────────────────
|
|
|
|
interface TokenDef {
|
|
key: keyof PaletteEntry;
|
|
label: string;
|
|
group: 'surface' | 'text' | 'accent' | 'border' | 'status' | 'message';
|
|
type: 'color';
|
|
}
|
|
|
|
const TOKEN_DEFS: TokenDef[] = [
|
|
// Surface
|
|
{ key: 'bg', label: 'Background', group: 'surface', type: 'color' },
|
|
{ key: 'bgSubtle', label: 'Subtle', group: 'surface', type: 'color' },
|
|
{ key: 'bgHover', label: 'Hover', group: 'surface', type: 'color' },
|
|
{ key: 'bgActive', label: 'Active', group: 'surface', type: 'color' },
|
|
{ key: 'surface', label: 'Surface (card)', group: 'surface', type: 'color' },
|
|
{ key: 'surface2', label: 'Surface 2', group: 'surface', type: 'color' },
|
|
{ key: 'panelBg', label: 'Panel / Sidebar', group: 'surface', type: 'color' },
|
|
// Text
|
|
{ key: 'text', label: 'Text', group: 'text', type: 'color' },
|
|
{ key: 'textMuted', label: 'Text Muted', group: 'text', type: 'color' },
|
|
{ key: 'textSubtle', label: 'Text Subtle', group: 'text', type: 'color' },
|
|
{ key: 'icon', label: 'Icon', group: 'text', type: 'color' },
|
|
{ key: 'iconHover', label: 'Icon Hover', group: 'text', type: 'color' },
|
|
// Accent
|
|
{ key: 'accent', label: 'Accent', group: 'accent', type: 'color' },
|
|
{ key: 'accentHover', label: 'Accent Hover', group: 'accent', type: 'color' },
|
|
{ key: 'accentText', label: 'Accent Text', group: 'accent', type: 'color' },
|
|
{ key: 'mentionBg', label: 'Mention BG', group: 'accent', type: 'color' },
|
|
{ key: 'mentionText', label: 'Mention Text', group: 'accent', type: 'color' },
|
|
// Border
|
|
{ key: 'border', label: 'Border', group: 'border', type: 'color' },
|
|
{ key: 'borderFocus', label: 'Border Focus', group: 'border', type: 'color' },
|
|
{ key: 'borderMuted', label: 'Border Muted', group: 'border', type: 'color' },
|
|
// Status
|
|
{ key: 'online', label: 'Online', group: 'status', type: 'color' },
|
|
{ key: 'away', label: 'Away', group: 'status', type: 'color' },
|
|
{ key: 'offline', label: 'Offline', group: 'status', type: 'color' },
|
|
// Message
|
|
{ key: 'msgBg', label: 'Message BG', group: 'message', type: 'color' },
|
|
{ key: 'msgOwnBg', label: 'Own Message BG', group: 'message', type: 'color' },
|
|
];
|
|
|
|
const GROUP_LABELS: Record<TokenDef['group'], string> = {
|
|
surface: 'Surface',
|
|
text: 'Text & Icon',
|
|
accent: 'Accent',
|
|
border: 'Border',
|
|
status: 'Status',
|
|
message: 'Message',
|
|
};
|
|
|
|
// ─── Preset Card ─────────────────────────────────────────────────────────────
|
|
|
|
function PresetCard({
|
|
preset,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
preset: (typeof THEME_PRESETS)[number];
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const { resolvedTheme } = useTheme();
|
|
|
|
const previewPalette = preset.palette ?? buildDefaultPreview(resolvedTheme);
|
|
|
|
const swatches = [
|
|
previewPalette.bg,
|
|
previewPalette.surface,
|
|
previewPalette.border,
|
|
previewPalette.text,
|
|
previewPalette.textMuted,
|
|
previewPalette.accent,
|
|
previewPalette.accentText,
|
|
];
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all w-full',
|
|
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
active ? 'border-primary ring-1 ring-primary' : 'border-border',
|
|
)}
|
|
>
|
|
{/* Color swatches */}
|
|
<div className="flex gap-1">
|
|
{swatches.map((color, i) => (
|
|
<div
|
|
key={i}
|
|
className="h-6 flex-1 rounded-sm"
|
|
style={{ background: color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
{/* Label */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium">{preset.label}</span>
|
|
{active && <Check className="h-3.5 w-3.5 text-primary" />}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
{preset.description}
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function buildDefaultPreview(theme: 'light' | 'dark'): PaletteEntry {
|
|
if (theme === 'dark') {
|
|
return {
|
|
bg: '#1a1a1e', bgSubtle: '#1e1e23', bgHover: '#222228', bgActive: '#2a2a30',
|
|
border: '#2e2e35', borderFocus: '#4a9eff', borderMuted: '#252528',
|
|
text: '#ececf1', textMuted: '#8a8a92', textSubtle: '#5c5c65',
|
|
accent: '#4a9eff', accentHover: '#6aafff', accentText: '#ffffff',
|
|
icon: '#7a7a84', iconHover: '#b0b0ba',
|
|
surface: '#222228', surface2: '#2a2a30',
|
|
online: '#34d399', away: '#fbbf24', offline: '#6b7280',
|
|
mentionBg: 'rgba(74,158,255,0.12)', mentionText: '#4a9eff',
|
|
msgBg: '#1e1e23', msgOwnBg: '#1a2a3a', panelBg: '#161619',
|
|
badgeAi: '', badgeRole: '',
|
|
};
|
|
}
|
|
return {
|
|
bg: '#ffffff', bgSubtle: '#f9f9fa', bgHover: '#f3f3f5', bgActive: '#ebebef',
|
|
border: '#e4e4e8', borderFocus: '#1c7ded', borderMuted: '#eeeeef',
|
|
text: '#1f1f1f', textMuted: '#8a8a8f', textSubtle: '#b8b8bd',
|
|
accent: '#1c7ded', accentHover: '#1a73d4', accentText: '#ffffff',
|
|
icon: '#8a8a8f', iconHover: '#5c5c62',
|
|
surface: '#f7f7f8', surface2: '#eeeeef',
|
|
online: '#22c55e', away: '#f59e0b', offline: '#d1d1d6',
|
|
mentionBg: 'rgba(28,125,237,0.08)', mentionText: '#1c7ded',
|
|
msgBg: '#f9f9fb', msgOwnBg: '#e8f0fe', panelBg: '#f9f9fa',
|
|
badgeAi: '', badgeRole: '',
|
|
};
|
|
}
|
|
|
|
// ─── Token Editor ─────────────────────────────────────────────────────────────
|
|
|
|
function TokenEditor({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: PaletteEntry;
|
|
onChange: (v: PaletteEntry) => void;
|
|
}) {
|
|
const groups = (['surface', 'text', 'accent', 'border', 'status', 'message'] as const);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{groups.map((group) => {
|
|
const defs = TOKEN_DEFS.filter((d) => d.group === group);
|
|
return (
|
|
<div key={group}>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
|
{GROUP_LABELS[group]}
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
|
{defs.map((def) => (
|
|
<div key={def.key} className="flex items-center gap-2">
|
|
{/* Swatch + native color picker */}
|
|
<div className="relative shrink-0">
|
|
<div
|
|
className="h-7 w-7 rounded border cursor-pointer overflow-hidden"
|
|
style={{ background: value[def.key] as string }}
|
|
>
|
|
<input
|
|
type="color"
|
|
value={(value[def.key] as string).startsWith('#')
|
|
? (value[def.key] as string)
|
|
: '#888888'}
|
|
onChange={(e) =>
|
|
onChange({ ...value, [def.key]: e.target.value })
|
|
}
|
|
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
|
title={def.label}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium truncate">{def.label}</p>
|
|
<input
|
|
type="text"
|
|
value={value[def.key] as string}
|
|
onChange={(e) =>
|
|
onChange({ ...value, [def.key]: e.target.value })
|
|
}
|
|
className="w-full bg-transparent border-0 p-0 text-[10px] text-muted-foreground focus:outline-none focus:ring-0 font-mono"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
|
|
interface ThemeSwitcherProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
export function ThemeSwitcher({ open, onOpenChange }: ThemeSwitcherProps) {
|
|
const { resolvedTheme } = useTheme();
|
|
|
|
const [activePresetId, setActivePresetId] = useState<ThemePresetId>(loadActivePresetId);
|
|
const [customPalette, setCustomPalette] = useState<PaletteEntry | null>(
|
|
loadCustomPalette,
|
|
);
|
|
// Working copy being edited
|
|
const [draft, setDraft] = useState<PaletteEntry | null>(null);
|
|
const [isDirty, setIsDirty] = useState(false);
|
|
// Token import
|
|
const [tokenInput, setTokenInput] = useState('');
|
|
const [tokenError, setTokenError] = useState('');
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Reset when panel opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
const id = loadActivePresetId();
|
|
setActivePresetId(id);
|
|
setCustomPalette(loadCustomPalette());
|
|
setDraft(id === 'custom' && loadCustomPalette() ? { ...loadCustomPalette()! } : null);
|
|
setIsDirty(false);
|
|
}
|
|
}, [open]);
|
|
|
|
const applyPreset = useCallback(
|
|
(presetId: ThemePresetId) => {
|
|
setActivePresetId(presetId);
|
|
saveActivePresetId(presetId);
|
|
if (presetId === 'custom') {
|
|
const stored = loadCustomPalette();
|
|
setCustomPalette(stored);
|
|
setDraft(stored ? { ...stored } : null);
|
|
if (stored) applyPaletteToDOM(stored);
|
|
} else {
|
|
clearCustomPalette();
|
|
setCustomPalette(null);
|
|
setDraft(null);
|
|
resetDOMFromPalette();
|
|
}
|
|
setIsDirty(false);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleDraftChange = useCallback((next: PaletteEntry) => {
|
|
setDraft(next);
|
|
setIsDirty(true);
|
|
}, []);
|
|
|
|
const handleApplyCustom = useCallback(() => {
|
|
if (!draft) return;
|
|
saveCustomPalette(draft);
|
|
setCustomPalette(draft);
|
|
applyPaletteToDOM(draft);
|
|
setActivePresetId('custom');
|
|
saveActivePresetId('custom');
|
|
setIsDirty(false);
|
|
}, [draft]);
|
|
|
|
const handleReset = useCallback(() => {
|
|
applyPreset('default');
|
|
}, [applyPreset]);
|
|
|
|
// Get the current theme's token for sharing
|
|
const currentToken = (() => {
|
|
if (activePresetId === 'custom' && customPalette) {
|
|
return encodeThemeToken(customPalette);
|
|
}
|
|
const preset = THEME_PRESETS.find((p) => p.id === activePresetId);
|
|
if (preset?.palette) {
|
|
return encodeThemeToken(preset.palette);
|
|
}
|
|
return null;
|
|
})();
|
|
|
|
const handleCopyToken = useCallback(() => {
|
|
if (!currentToken) return;
|
|
navigator.clipboard.writeText(currentToken).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
});
|
|
}, [currentToken]);
|
|
|
|
const handleImportToken = useCallback(() => {
|
|
const trimmed = tokenInput.trim();
|
|
if (!trimmed) {
|
|
setTokenError('Please paste a theme token');
|
|
return;
|
|
}
|
|
if (!isThemeToken(trimmed)) {
|
|
setTokenError('Invalid token format');
|
|
return;
|
|
}
|
|
const palette = decodeThemeToken(trimmed);
|
|
if (!palette) {
|
|
setTokenError('Could not decode token');
|
|
return;
|
|
}
|
|
setTokenError('');
|
|
setTokenInput('');
|
|
// Apply as custom palette
|
|
saveCustomPalette(palette);
|
|
saveActivePresetId('custom');
|
|
applyPaletteToDOM(palette);
|
|
setActivePresetId('custom');
|
|
setCustomPalette(palette);
|
|
setDraft({ ...palette });
|
|
setIsDirty(false);
|
|
}, [tokenInput]);
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent className="flex flex-col overflow-y-auto w-[360px] sm:max-w-[360px]">
|
|
<SheetHeader>
|
|
<SheetTitle className="flex items-center gap-2">
|
|
<Sliders className="h-4 w-4" />
|
|
Theme Settings
|
|
</SheetTitle>
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<LanguageSwitcher />
|
|
</div>
|
|
</SheetHeader>
|
|
|
|
{/* ── Scrollable content with padding ─────────────────────────────── */}
|
|
<div className="flex flex-col gap-5 px-5 pb-5 overflow-y-auto">
|
|
|
|
{/* ── Preset grid ─────────────────────────────────────────────────── */}
|
|
<div className="mt-6">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
|
Presets
|
|
</p>
|
|
<div className="grid grid-cols-1 gap-2">
|
|
{THEME_PRESETS.map((preset) => (
|
|
<PresetCard
|
|
key={preset.id}
|
|
preset={preset}
|
|
active={activePresetId === preset.id}
|
|
onClick={() => applyPreset(preset.id as ThemePresetId)}
|
|
/>
|
|
))}
|
|
{/* Custom preset card — always shown */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
// Switch to custom, seed draft from current effective palette
|
|
const seed = activePresetId === 'custom' && customPalette
|
|
? { ...customPalette }
|
|
: { ...buildDefaultPreview(resolvedTheme) };
|
|
setDraft(seed);
|
|
setActivePresetId('custom');
|
|
setIsDirty(false);
|
|
if (activePresetId !== 'custom') {
|
|
saveActivePresetId('custom');
|
|
}
|
|
}}
|
|
className={cn(
|
|
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-all',
|
|
'hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
activePresetId === 'custom'
|
|
? 'border-primary ring-1 ring-primary'
|
|
: 'border-border border-dashed',
|
|
)}
|
|
>
|
|
{/* Mini palette swatches */}
|
|
<div className="flex gap-1">
|
|
{['#ffffff', '#f9f9fa', '#e4e4e8', '#1f1f1f', '#8a8a8f', '#1c7ded', '#ffffff'].map(
|
|
(c, i) => (
|
|
<div key={i} className="h-6 flex-1 rounded-sm" style={{ background: c }} />
|
|
),
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium">Custom</span>
|
|
{activePresetId === 'custom' && (
|
|
<Check className="h-3.5 w-3.5 text-primary" />
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
Define your own colors
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Share / Import Token ────────────────────────────────────────── */}
|
|
<div className="border-t pt-5 space-y-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Share & Import
|
|
</p>
|
|
|
|
{/* Current token display + copy */}
|
|
{currentToken && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-[10px] text-muted-foreground">Current theme token</p>
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={currentToken}
|
|
className="flex-1 min-w-0 rounded border bg-muted/50 px-2 py-1.5 text-[10px] font-mono text-muted-foreground truncate focus:outline-none"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1 text-xs shrink-0"
|
|
onClick={handleCopyToken}
|
|
>
|
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
|
{copied ? 'Copied' : 'Copy'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import token */}
|
|
<div className="space-y-1.5">
|
|
<p className="text-[10px] text-muted-foreground">Import a theme token</p>
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={tokenInput}
|
|
onChange={(e) => { setTokenInput(e.target.value); setTokenError(''); }}
|
|
placeholder="T1:eyJi..."
|
|
className="flex-1 min-w-0 rounded border bg-background px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring"
|
|
spellCheck={false}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-8 gap-1 text-xs shrink-0"
|
|
onClick={handleImportToken}
|
|
>
|
|
<Download className="h-3 w-3" />
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
{tokenError && (
|
|
<p className="text-[10px] text-destructive">{tokenError}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Custom token editor ─────────────────────────────────────────── */}
|
|
{activePresetId === 'custom' && draft && (
|
|
<div className="border-t pt-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Token Editor
|
|
</p>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 text-xs"
|
|
onClick={handleReset}
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 gap-1 text-xs"
|
|
onClick={handleApplyCustom}
|
|
disabled={!isDirty}
|
|
>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<TokenEditor value={draft} onChange={handleDraftChange} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|