gitdataai/src/components/room/ThemeSwitcher.tsx
ZhenYi 867f216a1f feat(theme): add preset color schemes and theme token system
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)
2026-04-29 09:50:13 +08:00

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>
);
}