Support multiple named, saved custom themes per user

Replaces the single custom_theme_colors blob (one palette per user) with
a proper CustomTheme table -- users can now save, name, and switch
between as many custom palettes as they like, not just one.

Data model: users.active_custom_theme_id references whichever saved
CustomTheme (if any) is currently active; theme='custom' + that id
together determine what's rendered. The migration data-migrates any
already-saved single palette into a named CustomTheme row on upgrade,
and best-effort backfills the active one back into the old column shape
on downgrade.

New endpoints under /api/custom-themes: list, create, rename/recolor,
delete (falls back the user to a preset if the deleted theme was
active, so the two theme columns can never disagree), and activate.
UserRead.active_custom_theme is only populated when theme == 'custom'
even though the DB deliberately keeps the id set while a preset is
active, so switching to a preset and back doesn't lose the saved
palette.

ProfileModal now lists saved themes as swatches (click to activate,
pencil to edit -- active or not, trash to delete with a confirm), plus
a "+ New" button that creates, activates, and opens the editor for a
fresh theme immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:53:05 -06:00
co-authored by Claude Sonnet 5
parent 73020fa39f
commit bd3e621e9f
21 changed files with 972 additions and 203 deletions
+9 -7
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client'
import type { CustomThemeColors, ThemeName, User } from '../types'
import type { User } from '../types'
// No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -27,14 +27,16 @@ export function updateProfile(displayName: string | null): Promise<User> {
})
}
// Deliberately its own call sending only `theme` (and, for 'custom',
// `custom_theme_colors` alongside it) -- the backend only applies fields
// actually present in the request body, so this can't clobber display_name
// (and updateProfile above can't clobber theme).
export function updateTheme(theme: ThemeName, customThemeColors?: CustomThemeColors): Promise<User> {
// Deliberately its own call sending only `theme` -- the backend only
// applies fields actually present in the request body, so this can't
// clobber display_name (and updateProfile above can't clobber theme).
// Presets only ('dark'/'light'/'midnight'/'sunset') -- activating a custom
// theme is POST /api/custom-themes/{id}/activate (see api/customThemes.ts),
// since that needs an id and an ownership check, not just a bare name.
export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Promise<User> {
return apiFetch<User>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }),
body: JSON.stringify({ theme }),
})
}
+33
View File
@@ -0,0 +1,33 @@
import { apiFetch } from './client'
import type { CustomTheme, CustomThemeColors, User } from '../types'
export function listCustomThemes(): Promise<CustomTheme[]> {
return apiFetch<CustomTheme[]>('/api/custom-themes')
}
export function createCustomTheme(name: string, colors: CustomThemeColors): Promise<CustomTheme> {
return apiFetch<CustomTheme>('/api/custom-themes', {
method: 'POST',
body: JSON.stringify({ name, colors }),
})
}
// Each field independently optional-and-settable, same convention as
// updateProfile -- a rename shouldn't require resending all 12 colors.
export function updateCustomTheme(
id: string,
data: { name?: string; colors?: CustomThemeColors },
): Promise<CustomTheme> {
return apiFetch<CustomTheme>(`/api/custom-themes/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export function deleteCustomTheme(id: string): Promise<void> {
return apiFetch<void>(`/api/custom-themes/${id}`, { method: 'DELETE' })
}
export function activateCustomTheme(id: string): Promise<User> {
return apiFetch<User>(`/api/custom-themes/${id}/activate`, { method: 'POST' })
}
+66
View File
@@ -176,6 +176,72 @@
background: #fca050;
}
.custom-theme-swatch {
display: flex;
align-items: stretch;
gap: 4px;
border-radius: var(--radius);
}
.custom-theme-swatch.theme-swatch-selected {
box-shadow: 0 0 0 1px var(--ds-accent);
border-radius: var(--radius);
}
.custom-theme-swatch-select {
flex: 1;
min-width: 0;
}
.custom-theme-swatch-select .theme-swatch-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-theme-swatch-actions {
display: flex;
flex-direction: column;
gap: 2px;
flex: none;
}
.custom-theme-swatch-icon-btn {
flex: 1;
width: 24px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: 5px;
color: var(--ds-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.custom-theme-swatch-icon-btn:hover {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.custom-theme-new {
justify-content: center;
color: var(--ds-muted);
}
.theme-swatch-preview-new {
background: transparent;
border-style: dashed;
font-size: 1rem;
line-height: 1;
color: var(--ds-muted);
}
.custom-theme-name-input {
margin-bottom: var(--sp-3) !important;
}
.custom-theme-editor {
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
+204 -64
View File
@@ -1,15 +1,22 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { changePassword, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
import { ApiError } from '../api/client'
import {
activateCustomTheme,
createCustomTheme,
deleteCustomTheme,
listCustomThemes,
updateCustomTheme,
} from '../api/customThemes'
import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomThemeColors, ThemeName } from '../types'
import type { CustomTheme, CustomThemeColors } from '../types'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
const THEME_OPTIONS: { name: ThemeName; label: string }[] = [
const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: string }[] = [
{ name: 'dark', label: 'Dark' },
{ name: 'light', label: 'Light' },
{ name: 'midnight', label: 'Midnight' },
@@ -43,11 +50,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null)
const [customColors, setCustomColors] = useState<CustomThemeColors>(
user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS,
)
const [customColorsDirty, setCustomColorsDirty] = useState(false)
const [savingColors, setSavingColors] = useState(false)
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
const [editNameDraft, setEditNameDraft] = useState('')
const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS)
const [savingThemeEdit, setSavingThemeEdit] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
@@ -56,6 +64,15 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
useEffect(() => {
listCustomThemes()
.then(setCustomThemes)
.catch(() => {
// Non-critical -- the saved-themes list just stays empty; presets
// and everything else in this modal still work fine.
})
}, [])
if (!user) return null
async function handleSaveName(e: FormEvent) {
@@ -98,55 +115,114 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
}
async function handleSelectTheme(theme: ThemeName) {
async function handleSelectPreset(theme: 'dark' | 'light' | 'midnight' | 'sunset') {
// Instant visual feedback, then persist -- mirrors avatar upload's
// apply-immediately pattern rather than requiring a separate Save.
// For 'custom', this always sends the current draft palette alongside
// the theme name (previously-saved colors if any, else the defaults),
// so selecting Custom never leaves theme='custom' persisted with no
// palette behind it.
applyTheme(theme, theme === 'custom' ? customColors : null)
applyTheme(theme, null)
setThemeError(null)
try {
const updated = await updateTheme(theme, theme === 'custom' ? customColors : undefined)
const updated = await updateTheme(theme)
updateUser(updated)
setCustomColorsDirty(false)
} catch (err) {
// Revert the optimistic DOM change if it didn't actually persist.
applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null)
applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null)
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) {
const next = { ...customColors, [key]: value }
setCustomColors(next)
setCustomColorsDirty(true)
// Live preview only -- deliberately not persisted per keystroke (a
// native color input fires continuously while dragging), see
// handleSaveColors for the actual persist step.
applyTheme('custom', next)
}
async function handleSaveColors() {
setSavingColors(true)
async function handleActivateCustomTheme(theme: CustomTheme) {
applyTheme('custom', theme.colors)
setThemeError(null)
try {
const updated = await updateTheme('custom', customColors)
const updated = await activateCustomTheme(theme.id)
updateUser(updated)
setCustomColorsDirty(false)
} catch (err) {
applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null)
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleCreateCustomTheme() {
setThemeError(null)
try {
const created = await createCustomTheme('New theme', DEFAULT_CUSTOM_COLORS)
setCustomThemes((prev) => [...prev, created])
await handleActivateCustomTheme(created)
openEditor(created)
} catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
function openEditor(theme: CustomTheme) {
setEditingThemeId(theme.id)
setEditNameDraft(theme.name)
setEditColorsDraft(theme.colors)
setThemeError(null)
}
function closeEditor() {
// Only ever live-previewed on screen if this theme was already the
// active one (see handleEditColorChange) -- revert that preview back
// to whatever's actually persisted if it was never saved.
if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) {
applyTheme(user.theme, user.active_custom_theme.colors)
}
setEditingThemeId(null)
}
function handleEditColorChange(key: keyof CustomThemeColors, value: string) {
const next = { ...editColorsDraft, [key]: value }
setEditColorsDraft(next)
// Only reflect on the whole page live if the theme being edited is
// already the active one -- editing a theme you're not currently using
// shouldn't hijack what's on screen right now.
if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) {
applyTheme('custom', next)
}
}
async function handleSaveThemeEdit() {
if (!editingThemeId || !user) return
setSavingThemeEdit(true)
setThemeError(null)
try {
const updated = await updateCustomTheme(editingThemeId, {
name: editNameDraft.trim() || 'Untitled',
colors: editColorsDraft,
})
setCustomThemes((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
if (user.active_custom_theme?.id === updated.id) {
updateUser({ ...user, active_custom_theme: updated })
}
setEditingThemeId(null)
} catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err))
} finally {
setSavingColors(false)
setSavingThemeEdit(false)
}
}
async function handleDeleteCustomTheme(theme: CustomTheme) {
if (!confirm(`Delete "${theme.name}"? This can't be undone.`) || !user) return
setThemeError(null)
try {
await deleteCustomTheme(theme.id)
setCustomThemes((prev) => prev.filter((t) => t.id !== theme.id))
if (editingThemeId === theme.id) setEditingThemeId(null)
if (user.active_custom_theme?.id === theme.id) {
// The backend fell back to a preset for us -- pick that up rather
// than guessing what it chose.
const refreshed = await me()
updateUser(refreshed)
applyTheme(refreshed.theme, refreshed.active_custom_theme?.colors ?? null)
}
} catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
function handleClose() {
// Unsaved color edits were only ever a live preview -- revert to
// whatever's actually persisted so closing without saving doesn't leave
// the app visually stuck on a draft.
if (customColorsDirty) applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null)
if (editingThemeId) closeEditor()
onClose()
}
@@ -173,6 +249,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
}
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
return (
<div className="modal-scrim" onClick={handleClose}>
@@ -227,7 +304,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
className={`theme-swatch theme-swatch-${option.name}${
(user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : ''
}`}
onClick={() => handleSelectTheme(option.name)}
onClick={() => handleSelectPreset(option.name)}
aria-pressed={(user.theme ?? 'dark') === option.name}
>
<span className="theme-swatch-preview" aria-hidden="true">
@@ -236,33 +313,93 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<span className="theme-swatch-label">{option.label}</span>
</button>
))}
<button
type="button"
className={`theme-swatch${user.theme === 'custom' ? ' theme-swatch-selected' : ''}`}
onClick={() => handleSelectTheme('custom')}
aria-pressed={user.theme === 'custom'}
>
<span
className="theme-swatch-preview"
aria-hidden="true"
style={{ background: customColors.void }}
>
<span className="theme-swatch-accent" style={{ background: customColors.accent }} />
</span>
<span className="theme-swatch-label">Custom</span>
</button>
</div>
{themeError && <p className="modal-error">{themeError}</p>}
{user.theme === 'custom' && (
<div className="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid">
{customThemes.map((theme) => {
const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id
return (
<div key={theme.id} className={`custom-theme-swatch${active ? ' theme-swatch-selected' : ''}`}>
<button
type="button"
className="theme-swatch custom-theme-swatch-select"
onClick={() => handleActivateCustomTheme(theme)}
aria-pressed={active}
>
<span
className="theme-swatch-preview"
aria-hidden="true"
style={{ background: theme.colors.void }}
>
<span className="theme-swatch-accent" style={{ background: theme.colors.accent }} />
</span>
<span className="theme-swatch-label">{theme.name}</span>
</button>
<div className="custom-theme-swatch-actions">
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => (editingThemeId === theme.id ? closeEditor() : openEditor(theme))}
aria-label={`Edit ${theme.name}`}
title="Edit"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 3.5 16.5 6.5 7 16 3 17 4 13 13.5 3.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => handleDeleteCustomTheme(theme)}
aria-label={`Delete ${theme.name}`}
title="Delete"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M4 6h12M8 6V4h4v2m-6 0 .7 10.5A1 1 0 0 0 7.7 17h4.6a1 1 0 0 0 1-.95L14 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
)
})}
<button type="button" className="theme-swatch custom-theme-new" onClick={handleCreateCustomTheme}>
<span className="theme-swatch-preview theme-swatch-preview-new" aria-hidden="true">
+
</span>
<span className="theme-swatch-label">New</span>
</button>
</div>
{editingTheme && (
<div className="custom-theme-editor">
<input
type="text"
className="custom-theme-name-input"
value={editNameDraft}
onChange={(e) => setEditNameDraft(e.target.value)}
placeholder="Theme name"
maxLength={50}
/>
<div className="custom-theme-grid">
{CUSTOM_COLOR_FIELDS.map((field) => (
<label key={field.key} className="custom-theme-field">
<input
type="color"
value={customColors[field.key]}
onChange={(e) => handleCustomColorChange(field.key, e.target.value)}
value={editColorsDraft[field.key]}
onChange={(e) => handleEditColorChange(field.key, e.target.value)}
/>
<span>{field.label}</span>
</label>
@@ -273,28 +410,31 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<div className="custom-theme-scheme-toggle">
<button
type="button"
className={`btn-secondary${customColors.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'light')}
className={`btn-secondary${editColorsDraft.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleEditColorChange('color_scheme', 'light')}
>
Light
</button>
<button
type="button"
className={`btn-secondary${customColors.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'dark')}
className={`btn-secondary${editColorsDraft.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleEditColorChange('color_scheme', 'dark')}
>
Dark
</button>
</div>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={closeEditor}>
Cancel
</button>
<button
type="button"
className="btn-primary"
onClick={handleSaveColors}
disabled={savingColors || !customColorsDirty}
onClick={handleSaveThemeEdit}
disabled={savingThemeEdit}
>
{savingColors ? 'Saving…' : 'Save colors'}
{savingThemeEdit ? 'Saving…' : 'Save'}
</button>
</div>
</div>
+2 -2
View File
@@ -23,8 +23,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [offline, setOffline] = useState(false)
useEffect(() => {
applyTheme(user?.theme ?? null, user?.custom_theme_colors ?? null)
}, [user?.theme, user?.custom_theme_colors])
applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
}, [user?.theme, user?.active_custom_theme])
useEffect(() => {
authApi
+11 -2
View File
@@ -2,7 +2,7 @@ export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
// Matches exactly the CSS custom properties frontend/src/styles/themes.css
// overrides per built-in preset -- kept in sync with
// backend/app/schemas/user.py's CustomThemeColors.
// backend/app/schemas/custom_theme.py's CustomThemeColors.
export interface CustomThemeColors {
void: string
void_2: string
@@ -19,6 +19,13 @@ export interface CustomThemeColors {
color_scheme: 'light' | 'dark'
}
export interface CustomTheme {
id: string
name: string
colors: CustomThemeColors
created_at: string
}
export interface User {
id: string
username: string
@@ -27,7 +34,9 @@ export interface User {
is_site_admin: boolean
display_name: string | null
theme: ThemeName | null
custom_theme_colors: CustomThemeColors | null
// Only non-null when theme === 'custom' -- see UserRead's model_validator
// in backend/app/schemas/user.py.
active_custom_theme: CustomTheme | null
avatar_filename: string | null
appear_offline: boolean
created_at: string