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; 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(null) const [savingName, setSavingName] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const fileInputRef = useRef(null) const [themeError, setThemeError] = useState(null) const [customThemes, setCustomThemes] = useState([]) const [editingThemeId, setEditingThemeId] = useState(null) const [editNameDraft, setEditNameDraft] = useState('') const [editColorsDraft, setEditColorsDraft] = useState(DEFAULT_CUSTOM_COLORS) const [savingThemeEdit, setSavingThemeEdit] = useState(false) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [passwordError, setPasswordError] = useState(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) { 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 (
e.stopPropagation()}>

Profile settings

{avatarUrl && ( )}

Theme
{THEME_OPTIONS.map((option) => ( ))}
{themeError &&

{themeError}

}
My custom themes
{customThemes.map((theme) => { const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id return (
) })}
{editingTheme && (
setEditNameDraft(e.target.value)} placeholder="Theme name" maxLength={50} />
{CUSTOM_COLOR_FIELDS.map((field) => ( ))}
Native controls (scrollbars, form inputs)
)}
Display name
setDisplayName(e.target.value)} placeholder={user.username} maxLength={50} /> {error &&

{error}

}

Change password
setCurrentPassword(e.target.value)} placeholder="Current password" autoComplete="current-password" /> setNewPassword(e.target.value)} placeholder="New password" autoComplete="new-password" minLength={8} /> setConfirmPassword(e.target.value)} placeholder="Confirm new password" autoComplete="new-password" minLength={8} /> {passwordError &&

{passwordError}

} {passwordSuccess &&

Password updated.

}
) }