Add theming system: custom palettes, per-user colour mode, admin appearance page
- 4 built-in themes (Default, Pastel, High Contrast, Ocean Blue) seeded as JSON files in /config/themes/ on startup; custom themes can be created, edited, and deleted via the new admin Appearance page - All theme tokens applied via JS inline CSS properties (no hardcoded CSS blocks) - New `color_mode` column on users table (migration dd6ad2f2c211); users can override the admin-set global default in Settings - Backend: GET/PATCH /settings/appearance, full CRUD on /settings/themes - Frontend: AdminAppearancePage with theme grid + colour pickers, SettingsPage replaces placeholder with mode selector, useTheme rewritten to fetch from API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
import { useState } from "react";
|
||||
import { Palette, Monitor, Sun, Moon, Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppearanceSettings,
|
||||
getThemes,
|
||||
updateAppearanceSettings,
|
||||
createTheme,
|
||||
updateTheme,
|
||||
deleteTheme,
|
||||
type ThemeDefinition,
|
||||
type ThemeColors,
|
||||
} from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const hexToRgb = (hex: string): string => {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
const rgbToHex = (rgb: string): string => {
|
||||
const parts = rgb.split(" ").map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return "#000000";
|
||||
return "#" + parts.map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const TOKEN_LABELS: { key: keyof ThemeColors; label: string }[] = [
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "surface", label: "Surface" },
|
||||
{ key: "primary", label: "Primary" },
|
||||
{ key: "primary_hover", label: "Primary Hover" },
|
||||
{ key: "accent", label: "Accent" },
|
||||
{ key: "accent_hover", label: "Accent Hover" },
|
||||
{ key: "border", label: "Border" },
|
||||
{ key: "text_primary", label: "Text" },
|
||||
{ key: "text_muted", label: "Muted Text" },
|
||||
];
|
||||
|
||||
const EMPTY_COLORS: ThemeColors = {
|
||||
background: "248 250 252",
|
||||
surface: "255 255 255",
|
||||
primary: "37 99 235",
|
||||
primary_hover: "29 78 216",
|
||||
accent: "234 179 8",
|
||||
accent_hover: "202 138 4",
|
||||
border: "226 232 240",
|
||||
text_primary: "15 23 42",
|
||||
text_muted: "100 116 139",
|
||||
};
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
// ── Theme card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
selected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
theme: ThemeDefinition;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const swatchKeys: (keyof ThemeColors)[] = [
|
||||
"background",
|
||||
"surface",
|
||||
"primary",
|
||||
"accent",
|
||||
"border",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg border-2 p-3 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 bg-surface"
|
||||
)}
|
||||
>
|
||||
{/* Swatches */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{swatchKeys.map((key) => {
|
||||
const rgb = theme.light[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="h-5 w-5 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: `rgb(${rgb.replace(/ /g, ",")})` }}
|
||||
title={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-foreground">{theme.label}</p>
|
||||
{theme.builtin && (
|
||||
<span className="text-xs text-muted">Built-in</span>
|
||||
)}
|
||||
|
||||
{/* Edit / delete for custom themes */}
|
||||
{!theme.builtin && (
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
className="p-1 rounded text-muted hover:text-foreground"
|
||||
title="Edit theme"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="p-1 rounded text-muted hover:text-red-500"
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Color editor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsEditor({
|
||||
label,
|
||||
colors,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
colors: ThemeColors;
|
||||
onChange: (colors: ThemeColors) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-2">{label}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TOKEN_LABELS.map(({ key, label: tokenLabel }) => (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<label className="text-xs text-muted">{tokenLabel}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={rgbToHex(colors[key])}
|
||||
onChange={(e) =>
|
||||
onChange({ ...colors, [key]: hexToRgb(e.target.value) })
|
||||
}
|
||||
className="h-8 w-full rounded border border-border cursor-pointer"
|
||||
title={colors[key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Theme form (create / edit) ─────────────────────────────────────────────────
|
||||
|
||||
function ThemeForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
error,
|
||||
}: {
|
||||
initial?: ThemeDefinition;
|
||||
onSave: (data: { id: string; label: string; light: ThemeColors; dark: ThemeColors }) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const [id, setId] = useState(initial?.id ?? "");
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [light, setLight] = useState<ThemeColors>(initial?.light ?? EMPTY_COLORS);
|
||||
const [dark, setDark] = useState<ThemeColors>(initial?.dark ?? { ...EMPTY_COLORS, background: "15 23 42", surface: "30 41 59", text_primary: "203 213 225", text_muted: "148 163 184" });
|
||||
|
||||
const isEditing = !!initial;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 bg-surface mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{isEditing ? "Edit Theme" : "New Theme"}
|
||||
</h3>
|
||||
<button onClick={onCancel} className="text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Theme ID (slug)</label>
|
||||
<input
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
||||
placeholder="my-theme"
|
||||
maxLength={64}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Display Name</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="My Theme"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<ColorsEditor label="Light mode" colors={light} onChange={setLight} />
|
||||
<ColorsEditor label="Dark mode" colors={dark} onChange={setDark} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSave({ id, label, light, dark })}
|
||||
disabled={isSaving || (!isEditing && !id) || !label}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Theme"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminAppearancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { data: themes = [] } = useQuery({
|
||||
queryKey: ["themes"],
|
||||
queryFn: getThemes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<ColorMode | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTheme, setEditingTheme] = useState<ThemeDefinition | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const activeTheme = selectedTheme ?? appearance?.theme ?? "default";
|
||||
const activeMode = selectedMode ?? (appearance?.default_mode as ColorMode) ?? "system";
|
||||
|
||||
const saveAppearance = useMutation({
|
||||
mutationFn: updateAppearanceSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appearance"] });
|
||||
setSelectedTheme(null);
|
||||
setSelectedMode(null);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createTheme,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setShowForm(false);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateTheme>[1] }) =>
|
||||
updateTheme(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setEditingTheme(null);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTheme,
|
||||
onSuccess: (_, deletedId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
if (activeTheme === deletedId) setSelectedTheme("default");
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty =
|
||||
(selectedTheme !== null && selectedTheme !== appearance?.theme) ||
|
||||
(selectedMode !== null && selectedMode !== appearance?.default_mode);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Palette className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Appearance</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Theme selector ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Colour Theme</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Select the colour palette applied site-wide. Custom themes can be created below.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
selected={activeTheme === theme.id}
|
||||
onSelect={() => setSelectedTheme(theme.id)}
|
||||
onEdit={() => { setEditingTheme(theme); setShowForm(false); setFormError(""); }}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete "${theme.label}"?`)) deleteMutation.mutate(theme.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Default mode ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Global Default Mode</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Users can override this in their personal Settings.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSelectedMode(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
activeMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Save button ── */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
saveAppearance.mutate({ theme: activeTheme, default_mode: activeMode })
|
||||
}
|
||||
disabled={saveAppearance.isPending || !isDirty}
|
||||
>
|
||||
{saveAppearance.isPending ? "Saving…" : "Save Appearance"}
|
||||
</Button>
|
||||
{saveAppearance.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save.</p>
|
||||
)}
|
||||
{saveAppearance.isSuccess && !isDirty && (
|
||||
<p className="mt-2 text-sm text-green-600">Saved.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Custom themes ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-base font-semibold text-foreground">Custom Themes</h2>
|
||||
{!showForm && !editingTheme && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(true); setEditingTheme(null); setFormError(""); }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Theme
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Create your own colour palettes. Each theme is stored as a file and persists
|
||||
across container restarts.
|
||||
</p>
|
||||
|
||||
{showForm && (
|
||||
<ThemeForm
|
||||
onSave={({ id, label, light, dark }) =>
|
||||
createMutation.mutate({ id, label, light, dark })
|
||||
}
|
||||
onCancel={() => { setShowForm(false); setFormError(""); }}
|
||||
isSaving={createMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTheme && (
|
||||
<ThemeForm
|
||||
initial={editingTheme}
|
||||
onSave={({ label, light, dark }) =>
|
||||
editMutation.mutate({ id: editingTheme.id, data: { label, light, dark } })
|
||||
}
|
||||
onCancel={() => { setEditingTheme(null); setFormError(""); }}
|
||||
isSaving={editMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showForm && !editingTheme && themes.filter((t) => !t.builtin).length === 0 && (
|
||||
<p className="mt-3 text-sm text-muted italic">No custom themes yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,77 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { Settings, Monitor, Sun, Moon } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getMe, getAppearanceSettings, updateColorMode } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const currentMode: ColorMode = (me?.color_mode as ColorMode) ?? "system";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateColorMode,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }),
|
||||
});
|
||||
|
||||
const adminDefault = appearance?.default_mode ?? "system";
|
||||
const adminDefaultLabel =
|
||||
adminDefault === "system" ? "system preference" : adminDefault + " mode";
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
||||
</div>
|
||||
<p className="text-muted text-sm">
|
||||
User and application settings will be available here in a future update.
|
||||
</p>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Appearance</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Choose your preferred colour mode. Overrides the site-wide default set by your
|
||||
administrator.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => mutation.mutate(value)}
|
||||
disabled={mutation.isPending}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
currentMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
Site-wide default: <span className="font-medium">{adminDefaultLabel}</span>
|
||||
</p>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save preference.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user