Files
ds-chat/frontend/src/components/ProfileModal.tsx
T
ksmithandClaude Sonnet 5 bd3e621e9f 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>
2026-08-17 07:53:05 -06:00

508 lines
18 KiB
TypeScript

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 { CustomTheme, CustomThemeColors } from '../types'
import { UserAvatar } from './UserAvatar'
import './Modal.css'
const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: string }[] = [
{ name: 'dark', label: 'Dark' },
{ name: 'light', label: 'Light' },
{ name: 'midnight', label: 'Midnight' },
{ name: 'sunset', label: 'Sunset' },
]
const CUSTOM_COLOR_FIELDS: { key: keyof Omit<CustomThemeColors, 'color_scheme'>; label: string }[] = [
{ key: 'void', label: 'Background' },
{ key: 'void_2', label: 'Sidebar background' },
{ key: 'surface', label: 'Surface' },
{ key: 'surface_2', label: 'Surface (secondary)' },
{ key: 'border', label: 'Border' },
{ key: 'text', label: 'Text' },
{ key: 'muted', label: 'Muted text' },
{ key: 'accent', label: 'Accent' },
{ key: 'accent_2', label: 'Accent (secondary)' },
{ key: 'accent_3', label: 'Accent (tertiary)' },
{ key: 'highlight', label: 'Highlight' },
{ key: 'danger', label: 'Danger' },
]
interface ProfileModalProps {
onClose: () => void
}
export function ProfileModal({ onClose }: ProfileModalProps) {
const { user, updateUser } = useAuth()
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
const [error, setError] = useState<string | null>(null)
const [savingName, setSavingName] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null)
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('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState<string | null>(null)
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) {
e.preventDefault()
setSavingName(true)
setError(null)
try {
const updated = await updateProfile(displayName.trim() || null)
updateUser(updated)
handleClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
setSavingName(false)
}
}
async function handleFileSelected(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
setUploadingAvatar(true)
setError(null)
try {
const updated = await uploadAvatar(file)
updateUser(updated)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setUploadingAvatar(false)
}
}
async function handleRemoveAvatar() {
setError(null)
try {
const updated = await removeAvatar()
updateUser(updated)
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
}
}
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.
applyTheme(theme, null)
setThemeError(null)
try {
const updated = await updateTheme(theme)
updateUser(updated)
} catch (err) {
applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null)
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleActivateCustomTheme(theme: CustomTheme) {
applyTheme('custom', theme.colors)
setThemeError(null)
try {
const updated = await activateCustomTheme(theme.id)
updateUser(updated)
} 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 {
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() {
if (editingThemeId) closeEditor()
onClose()
}
async function handleChangePassword(e: FormEvent) {
e.preventDefault()
setPasswordError(null)
setPasswordSuccess(false)
if (newPassword !== confirmPassword) {
setPasswordError("New passwords don't match")
return
}
setSavingPassword(true)
try {
await changePassword(currentPassword, newPassword)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setPasswordSuccess(true)
} catch (err) {
setPasswordError(err instanceof ApiError ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
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}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Profile settings</h2>
<button type="button" className="modal-close" onClick={handleClose} aria-label="Close">
&times;
</button>
</div>
<div className="profile-modal-avatar-row">
<UserAvatar
username={user.username}
colorIndex={hashIndex(user.username)}
size={64}
avatarUrl={avatarUrl}
status={user.appear_offline ? 'offline' : 'online'}
/>
<div className="profile-modal-avatar-actions">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="modal-hidden-file-input"
onChange={handleFileSelected}
/>
<button
type="button"
className="btn-secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
>
{uploadingAvatar ? 'Uploading…' : 'Upload photo'}
</button>
{avatarUrl && (
<button type="button" className="btn-secondary" onClick={handleRemoveAvatar}>
Remove photo
</button>
)}
</div>
</div>
<hr className="modal-divider" />
<div className="modal-field-label">Theme</div>
<div className="theme-swatch-grid">
{THEME_OPTIONS.map((option) => (
<button
key={option.name}
type="button"
className={`theme-swatch theme-swatch-${option.name}${
(user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : ''
}`}
onClick={() => handleSelectPreset(option.name)}
aria-pressed={(user.theme ?? 'dark') === option.name}
>
<span className="theme-swatch-preview" aria-hidden="true">
<span className="theme-swatch-accent" />
</span>
<span className="theme-swatch-label">{option.label}</span>
</button>
))}
</div>
{themeError && <p className="modal-error">{themeError}</p>}
<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={editColorsDraft[field.key]}
onChange={(e) => handleEditColorChange(field.key, e.target.value)}
/>
<span>{field.label}</span>
</label>
))}
</div>
<div className="custom-theme-scheme">
<span>Native controls (scrollbars, form inputs)</span>
<div className="custom-theme-scheme-toggle">
<button
type="button"
className={`btn-secondary${editColorsDraft.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleEditColorChange('color_scheme', 'light')}
>
Light
</button>
<button
type="button"
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={handleSaveThemeEdit}
disabled={savingThemeEdit}
>
{savingThemeEdit ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)}
<hr className="modal-divider" />
<form onSubmit={handleSaveName}>
<div className="modal-field-label">Display name</div>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={user.username}
maxLength={50}
/>
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={handleClose}>
Close
</button>
<button type="submit" className="btn-primary" disabled={savingName}>
Save
</button>
</div>
</form>
<hr className="modal-divider" />
<form onSubmit={handleChangePassword}>
<div className="modal-field-label">Change password</div>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Current password"
autoComplete="current-password"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New password"
autoComplete="new-password"
minLength={8}
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
autoComplete="new-password"
minLength={8}
/>
{passwordError && <p className="modal-error">{passwordError}</p>}
{passwordSuccess && <p className="modal-success">Password updated.</p>}
<div className="modal-actions">
<button
type="submit"
className="btn-primary"
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
>
{savingPassword ? 'Saving…' : 'Update password'}
</button>
</div>
</form>
</div>
</div>
)
}