From 0809f4e7876e3ef03444912d42d6bcfbd3eae9e1 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Mar 2026 18:43:52 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20settings=20persistence=20=E2=80=94?= =?UTF-8?q?=20profile,=20preferences=20save=20to=20DB=20(#124)=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../web/src/app/(dashboard)/settings/page.tsx | 561 ++++++++++++++++-- 1 file changed, 501 insertions(+), 60 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/page.tsx b/apps/web/src/app/(dashboard)/settings/page.tsx index 7469f51..a6342f6 100644 --- a/apps/web/src/app/(dashboard)/settings/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/page.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api'; -import { useSession } from '@/lib/auth-client'; +import { authClient, useSession } from '@/lib/auth-client'; + +// ─── Types ──────────────────────────────────────────────────────────────────── interface ModelInfo { id: string; @@ -37,8 +39,390 @@ interface ProviderTestStatus { result?: TestConnectionResult; } +interface Preference { + key: string; + value: unknown; + category: string; +} + +type Theme = 'light' | 'dark' | 'system'; +type SaveState = 'idle' | 'saving' | 'saved' | 'error'; +type Tab = 'profile' | 'appearance' | 'notifications' | 'providers'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function prefValue(prefs: Preference[], key: string, fallback: T): T { + const p = prefs.find((x) => x.key === key); + if (p === undefined) return fallback; + return p.value as T; +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + export default function SettingsPage(): React.ReactElement { const { data: session } = useSession(); + const [activeTab, setActiveTab] = useState('profile'); + + const tabs: { id: Tab; label: string }[] = [ + { id: 'profile', label: 'Profile' }, + { id: 'appearance', label: 'Appearance' }, + { id: 'notifications', label: 'Notifications' }, + { id: 'providers', label: 'Providers' }, + ]; + + return ( +
+

Settings

+ + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {activeTab === 'profile' && } + {activeTab === 'appearance' && } + {activeTab === 'notifications' && } + {activeTab === 'providers' && } +
+ ); +} + +// ─── Profile Tab ────────────────────────────────────────────────────────────── + +function ProfileTab({ + session, +}: { + session: { user: { id: string; name: string; email: string; image?: string | null } } | null; +}): React.ReactElement { + const [name, setName] = useState(session?.user.name ?? ''); + const [image, setImage] = useState(session?.user.image ?? ''); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + // Sync from session when it loads + useEffect(() => { + if (session?.user) { + setName(session.user.name ?? ''); + setImage(session.user.image ?? ''); + } + }, [session]); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + const result = await authClient.updateUser({ name, image: image || null }); + if (result.error) { + setErrorMsg(result.error.message ?? 'Failed to update profile'); + setSaveState('error'); + return; + } + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to update profile'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + return ( +
+

Profile

+
+ + setName(e.target.value)} + placeholder="Your name" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> + + + + +

Email cannot be changed here.

+
+ + + setImage(e.target.value)} + placeholder="https://example.com/avatar.png" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> + + +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Appearance Tab ─────────────────────────────────────────────────────────── + +function AppearanceTab(): React.ReactElement { + const [loading, setLoading] = useState(true); + const [theme, setTheme] = useState('system'); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [defaultModel, setDefaultModel] = useState(''); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + api('/api/memory/preferences?category=appearance') + .catch(() => [] as Preference[]) + .then((p) => { + setTheme(prefValue(p, 'ui.theme', 'system')); + setSidebarCollapsed(prefValue(p, 'ui.sidebar_collapsed', false)); + setDefaultModel(prefValue(p, 'ui.default_model', '')); + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + await Promise.all([ + api('/api/memory/preferences', { + method: 'POST', + body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'ui.sidebar_collapsed', + value: sidebarCollapsed, + category: 'appearance', + source: 'user', + }, + }), + ...(defaultModel + ? [ + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'ui.default_model', + value: defaultModel, + category: 'appearance', + source: 'user', + }, + }), + ] + : []), + ]); + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save preferences'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + if (loading) { + return ( +
+

Appearance

+

Loading preferences...

+
+ ); + } + + return ( +
+

Appearance

+
+ {/* Theme */} +
+ +
+ {(['system', 'light', 'dark'] as Theme[]).map((t) => ( + + ))} +
+
+ + {/* Sidebar collapsed default */} +
+
+

Collapse sidebar by default

+

Start with sidebar collapsed on page load

+
+ +
+ + {/* Default model */} + + setDefaultModel(e.target.value)} + placeholder="e.g. ollama/llama3.2" + className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" + /> +

+ Model ID to pre-select for new conversations. +

+
+ +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Notifications Tab ──────────────────────────────────────────────────────── + +function NotificationsTab(): React.ReactElement { + const [loading, setLoading] = useState(true); + const [emailAgentComplete, setEmailAgentComplete] = useState(false); + const [emailMentions, setEmailMentions] = useState(true); + const [emailDigest, setEmailDigest] = useState(false); + const [saveState, setSaveState] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + api('/api/memory/preferences?category=communication') + .catch(() => [] as Preference[]) + .then((p) => { + setEmailAgentComplete(prefValue(p, 'notify.email_agent_complete', false)); + setEmailMentions(prefValue(p, 'notify.email_mentions', true)); + setEmailDigest(prefValue(p, 'notify.email_digest', false)); + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async (): Promise => { + setSaveState('saving'); + setErrorMsg(''); + try { + await Promise.all([ + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_agent_complete', + value: emailAgentComplete, + category: 'communication', + source: 'user', + }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_mentions', + value: emailMentions, + category: 'communication', + source: 'user', + }, + }), + api('/api/memory/preferences', { + method: 'POST', + body: { + key: 'notify.email_digest', + value: emailDigest, + category: 'communication', + source: 'user', + }, + }), + ]); + setSaveState('saved'); + setTimeout(() => setSaveState('idle'), 2000); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to save preferences'; + setErrorMsg(message); + setSaveState('error'); + } + }; + + if (loading) { + return ( +
+

Notifications

+

Loading preferences...

+
+ ); + } + + return ( +
+

Notifications

+
+

Configure when you receive email notifications.

+ + + + + +
+ + {saveState === 'error' && errorMsg &&

{errorMsg}

} +
+
+
+ ); +} + +// ─── Providers Tab ──────────────────────────────────────────────────────────── + +function ProvidersTab(): React.ReactElement { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [testStatuses, setTestStatuses] = useState>({}); @@ -75,68 +459,134 @@ export default function SettingsPage(): React.ReactElement { } }, []); - // Derive default model: first available model across all providers const defaultModel: ModelInfo | undefined = providers .flatMap((p) => p.models) .find((m) => providers.find((p) => p.id === m.provider)?.available); return ( -
-

Settings

- - {/* Profile */} -
-

Profile

+
+

LLM Providers

+ {loading ? ( +

Loading providers...

+ ) : providers.length === 0 ? (
- {session?.user ? ( -
- - - -
- ) : ( -

Not signed in

- )} +

+ No providers configured. Set{' '} + OLLAMA_BASE_URL{' '} + or{' '} + + MOSAIC_CUSTOM_PROVIDERS + {' '} + to add providers. +

-
+ ) : ( +
+ {providers.map((provider) => ( + void testConnection(provider.id)} + /> + ))} +
+ )} +
+ ); +} - {/* Providers */} -
-

LLM Providers

- {loading ? ( -

Loading providers...

- ) : providers.length === 0 ? ( -
-

- No providers configured. Set{' '} - - OLLAMA_BASE_URL - {' '} - or{' '} - - MOSAIC_CUSTOM_PROVIDERS - {' '} - to add providers. -

-
- ) : ( -
- {providers.map((provider) => ( - void testConnection(provider.id)} - /> - ))} -
- )} -
+// ─── Shared UI Components ───────────────────────────────────────────────────── + +function FormField({ + label, + id, + children, +}: { + label: string; + id: string; + children: React.ReactNode; +}): React.ReactElement { + return ( +
+ + {children}
); } +function Toggle({ + checked, + onChange, +}: { + checked: boolean; + onChange: (v: boolean) => void; +}): React.ReactElement { + return ( + + ); +} + +function NotifyRow({ + label, + description, + checked, + onChange, +}: { + label: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; +}): React.ReactElement { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} + +function SaveButton({ + state, + onClick, +}: { + state: SaveState; + onClick: () => void; +}): React.ReactElement { + return ( + + ); +} + +// ─── Provider Card (from original page) ────────────────────────────────────── + interface ProviderCardProps { provider: ProviderInfo; defaultModel: ModelInfo | undefined; @@ -351,15 +801,6 @@ function CapabilityBadge({ return {label}; } -function Field({ label, value }: { label: string; value: string }): React.ReactElement { - return ( -
- {label} - {value} -
- ); -} - function formatContext(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;