Implements GET/PATCH/PUT /users/me/preferences. Fixes profile page 'Preferences unavailable' error by correcting the /api prefix in frontend calls and adding PATCH handler to controller. Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
325 lines
8.2 KiB
TypeScript
325 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import Link from "next/link";
|
|
import { useTheme } from "@/providers/ThemeProvider";
|
|
import { getAllThemes, type ThemeDefinition } from "@/themes";
|
|
import { apiPatch } from "@/lib/api/client";
|
|
|
|
function ThemeCard({
|
|
theme,
|
|
isActive,
|
|
onSelect,
|
|
}: {
|
|
theme: ThemeDefinition;
|
|
isActive: boolean;
|
|
onSelect: () => void;
|
|
}): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<button
|
|
onClick={onSelect}
|
|
onMouseEnter={() => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
padding: 16,
|
|
borderRadius: "var(--r-lg)",
|
|
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
|
border: isActive
|
|
? "2px solid var(--primary)"
|
|
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
transition: "all 0.15s ease",
|
|
position: "relative",
|
|
width: "100%",
|
|
}}
|
|
aria-label={`Select ${theme.name} theme`}
|
|
aria-pressed={isActive}
|
|
>
|
|
{/* Color preview swatches */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 0,
|
|
borderRadius: "var(--r)",
|
|
overflow: "hidden",
|
|
height: 48,
|
|
width: "100%",
|
|
border: "1px solid rgba(128, 128, 128, 0.15)",
|
|
}}
|
|
>
|
|
{theme.colorPreview.map((color, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
flex: 1,
|
|
background: color,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Theme info */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{theme.name}
|
|
</span>
|
|
{isActive && (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="var(--primary)"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="13 4 6 12 3 9" />
|
|
</svg>
|
|
)}
|
|
<span
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
padding: "2px 6px",
|
|
borderRadius: "var(--r-sm)",
|
|
background: theme.isDark ? "rgba(128,128,128,0.15)" : "rgba(245,158,11,0.12)",
|
|
color: theme.isDark ? "var(--muted)" : "var(--warn)",
|
|
fontWeight: 500,
|
|
marginLeft: "auto",
|
|
}}
|
|
>
|
|
{theme.isDark ? "Dark" : "Light"}
|
|
</span>
|
|
</div>
|
|
|
|
<p
|
|
style={{
|
|
fontSize: "0.78rem",
|
|
color: "var(--muted)",
|
|
margin: 0,
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{theme.description}
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SystemThemeCard({
|
|
isActive,
|
|
onSelect,
|
|
}: {
|
|
isActive: boolean;
|
|
onSelect: () => void;
|
|
}): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<button
|
|
onClick={onSelect}
|
|
onMouseEnter={() => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={() => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
padding: 16,
|
|
borderRadius: "var(--r-lg)",
|
|
background: isActive ? "var(--surface-2)" : hovered ? "var(--surface)" : "transparent",
|
|
border: isActive
|
|
? "2px solid var(--primary)"
|
|
: `1px solid ${hovered ? "var(--border)" : "transparent"}`,
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
transition: "all 0.15s ease",
|
|
width: "100%",
|
|
}}
|
|
aria-label="Use system theme preference"
|
|
aria-pressed={isActive}
|
|
>
|
|
{/* Split preview (dark | light) */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 0,
|
|
borderRadius: "var(--r)",
|
|
overflow: "hidden",
|
|
height: 48,
|
|
width: "100%",
|
|
border: "1px solid rgba(128, 128, 128, 0.15)",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, background: "#0f141d" }} />
|
|
<div style={{ flex: 1, background: "#1b2331" }} />
|
|
<div style={{ flex: 1, background: "#f0f4fc" }} />
|
|
<div style={{ flex: 1, background: "#dde4f2" }} />
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
background: "linear-gradient(135deg, #2f80ff 50%, #8b5cf6 50%)",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<span style={{ fontSize: "0.9rem", fontWeight: 600, color: "var(--text)" }}>System</span>
|
|
{isActive && (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
stroke="var(--primary)"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<polyline points="13 4 6 12 3 9" />
|
|
</svg>
|
|
)}
|
|
<span
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
padding: "2px 6px",
|
|
borderRadius: "var(--r-sm)",
|
|
background: "rgba(47, 128, 255, 0.12)",
|
|
color: "var(--primary-l)",
|
|
fontWeight: 500,
|
|
marginLeft: "auto",
|
|
}}
|
|
>
|
|
Auto
|
|
</span>
|
|
</div>
|
|
|
|
<p
|
|
style={{
|
|
fontSize: "0.78rem",
|
|
color: "var(--muted)",
|
|
margin: 0,
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
Follows your operating system appearance preference
|
|
</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function AppearanceSettingsPage(): ReactElement {
|
|
const { theme: preference, setTheme: setLocalTheme } = useTheme();
|
|
const [saving, setSaving] = useState(false);
|
|
const allThemes = getAllThemes();
|
|
|
|
const handleThemeSelect = useCallback(
|
|
async (themeId: string) => {
|
|
setLocalTheme(themeId);
|
|
setSaving(true);
|
|
try {
|
|
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
|
} catch {
|
|
// Theme is still applied locally even if API save fails
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
},
|
|
[setLocalTheme]
|
|
);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-6">
|
|
{/* Breadcrumb */}
|
|
<div style={{ marginBottom: 8 }}>
|
|
<Link
|
|
href="/settings"
|
|
style={{
|
|
fontSize: "0.83rem",
|
|
color: "var(--muted)",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
Settings
|
|
</Link>
|
|
<span style={{ fontSize: "0.83rem", color: "var(--muted)", margin: "0 6px" }}>/</span>
|
|
<span style={{ fontSize: "0.83rem", color: "var(--text-2)" }}>Appearance</span>
|
|
</div>
|
|
|
|
{/* Page header */}
|
|
<div style={{ marginBottom: 32 }}>
|
|
<h1
|
|
style={{
|
|
fontSize: "1.875rem",
|
|
fontWeight: 700,
|
|
color: "var(--text)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Appearance
|
|
</h1>
|
|
<p
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
color: "var(--muted)",
|
|
margin: "8px 0 0 0",
|
|
}}
|
|
>
|
|
Choose a theme for the Mosaic interface
|
|
{saving && (
|
|
<span style={{ marginLeft: 12, color: "var(--primary-l)", fontStyle: "italic" }}>
|
|
Saving...
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Theme grid */}
|
|
<div
|
|
className="grid gap-3"
|
|
style={{
|
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
|
}}
|
|
>
|
|
{/* System option first */}
|
|
<SystemThemeCard
|
|
isActive={preference === "system"}
|
|
onSelect={() => void handleThemeSelect("system")}
|
|
/>
|
|
|
|
{/* All registered themes */}
|
|
{allThemes.map((t) => (
|
|
<ThemeCard
|
|
key={t.id}
|
|
theme={t}
|
|
isActive={preference === t.id}
|
|
onSelect={() => void handleThemeSelect(t.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|