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:
curo1305
2026-04-18 01:46:17 +02:00
parent da9b911f1e
commit 608b0b7fe8
15 changed files with 1063 additions and 34 deletions
+445
View File
@@ -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>
);
}
+67 -5
View File
@@ -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>
);
}