import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Bell, Monitor, Trash2, User } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; import { LS, usePersistedState } from "../lib/storage"; import Avatar from "./Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./Tooltip"; import { Section, SettingRow, Switch } from "./ui/form"; import { DraftSaveBar } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; // The Settings page edits server-persisted preferences as a draft: changes apply locally // for instant preview but reach the server only on an explicit Save (or revert on Discard). // App owns the draft + baseline (so the leave-the-page guard can see it); this controller is // how the panel reads/writes it. See App.tsx. export type PrefsSaveState = "idle" | "saving" | "saved" | "error"; export interface PrefsController { theme: ThemePrefs; setTheme: (t: ThemePrefs) => void; view: "grid" | "list"; setView: (v: "grid" | "list") => void; perf: boolean; setPerf: (v: boolean) => void; hints: boolean; setHints: (v: boolean) => void; notif: NotifSettings; setNotif: (n: NotifSettings) => void; dirty: boolean; save: () => void; discard: () => void; saveState: PrefsSaveState; } type TabId = "appearance" | "notifications" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, { id: "notifications", icon: Bell }, { id: "account", icon: User }, ]; // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. export default function SettingsPanel({ me, prefs, onOpenWizard, }: { me: Me; prefs: PrefsController; onOpenWizard: () => void; }) { const { t } = useTranslation(); const [tabRaw, setTab] = usePersistedState(LS.settingsTab, "appearance"); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) ? (tabRaw as TabId) : "appearance"; return (
{/* Vertical tab rail — never wraps; active is a clear accent pill. */} {/* Render only the active tab so the panel sizes to its actual content. (Stacking every tab in one grid cell made the whole panel as tall as the tallest tab — Account — leaving the short tabs with dead space and a needless scrollbar.) */}
{tab === "appearance" && } {tab === "notifications" && } {tab === "account" && }
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
); } function PrefsSaveBar({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); return ( ); } function Appearance({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); // Controlled by App's prefs draft: each change applies locally for preview but is only // persisted on an explicit Save (handled by the panel's Save/Discard bar). const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs; return ( <>
{SCHEMES.map((s) => (
setTheme({ ...theme, mode: v ? "dark" : "light" })} /> setView(v ? "list" : "grid")} />
setTheme({ ...theme, fontScale: Number(e.target.value) })} className="w-full accent-accent" />
{Math.round(theme.fontScale * 100)}%
); } function Notifications({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); // Controlled by App's prefs draft (live preview via configureNotifications there); the // panel's Save bar persists it. The "send test" button below still fires immediately. const { notif: cfg, setNotif } = prefs; const update = (p: Partial) => setNotif({ ...cfg, ...p }); const autoOn = cfg.durationMs > 0; return (
update({ sound: v })} /> update({ durationMs: v ? 6000 : 0 })} /> {autoOn && (
{t("settings.notifications.dismissAfter")} {(cfg.durationMs / 1000).toFixed(0)}s
update({ durationMs: Number(e.target.value) })} className="w-full accent-accent mt-1" />
)}
); } function AccessRow({ title, granted, grantedHint, enableHint, onEnable, }: { title: string; granted: boolean; grantedHint: string; enableHint: string; onEnable: () => void; }) { const { t } = useTranslation(); return (
{title}

{granted ? grantedHint : enableHint}

{granted ? ( {t("settings.account.granted")} ) : ( )}
); } // Sign-in methods: link a Google account to a password account (or vice-versa set a password), // so either method can reach the same account. Google connect is a full-page OAuth round-trip // (auth.py attaches the identity to the current session via /auth/link); the password form posts // directly with inline errors. Demo accounts never see this (handled by the caller). function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const submit = async (e: React.FormEvent) => { e.preventDefault(); setErr(null); setBusy(true); try { await api.setPassword(next, me.has_password ? current : undefined); setOpen(false); setCurrent(""); setNext(""); // Refresh `me` so has_password flips and the section switches to "Change password". await qc.invalidateQueries({ queryKey: ["me"] }); notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }) }); } catch (e: any) { setErr(e?.detail ?? t("settings.account.password.failed")); } finally { setBusy(false); } }; const inputCls = "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; // Offer Google only when this instance has Google OAuth configured (or the account is already // linked — then we still show its "Connected" status even if the instance creds were removed). const showGoogle = me.google_enabled || me.has_google; return (
{showGoogle && (
{t("settings.account.googleLink.title")}

{me.has_google ? t("settings.account.googleLink.connectedHint") : t("settings.account.googleLink.connectHint")}

{me.has_google ? ( {t("settings.account.googleLink.connected")} ) : ( )}
)}
{t("settings.account.password.title")}

{me.has_password ? t("settings.account.password.setHint") : t("settings.account.password.unsetHint")}

{open && (
{me.has_password && ( setCurrent(e.target.value)} placeholder={t("settings.account.password.current")} autoComplete="current-password" className={inputCls} /> )} setNext(e.target.value)} placeholder={t("settings.account.password.new")} autoComplete="new-password" className={inputCls} /> {err &&

{err}

}
)}
); } function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { const { t } = useTranslation(); const confirm = useConfirm(); const onDeleteAccount = async () => { const ok = await confirm({ title: t("settings.account.deleteTitle"), message: t("settings.account.deleteConfirm"), confirmLabel: t("settings.account.deleteConfirmButton"), danger: true, }); if (!ok) return; try { await api.deleteAccount(); // Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner. window.location.href = "/?deleted=1"; } catch { /* the global error dialog surfaces the reason (e.g. last admin) */ } }; return ( <>
{me.display_name ?? me.email.split("@")[0]}
{me.email}
{me.role}
{!me.is_demo && } {me.is_demo ? (

{t("settings.account.demoNotice")}

) : me.google_enabled ? ( // YouTube access requires Google OAuth; hidden when this instance has no Google configured.

{t("settings.account.youtubeAccessIntro")}

{ window.location.href = "/auth/upgrade?access=read"; }} /> { window.location.href = "/auth/upgrade?access=write"; }} />
) : null} {!me.is_demo && (

{t("settings.account.deleteHint")}

)} ); }