import { useState } from "react"; import { connectGoogle } from "../lib/googleConnect"; import { qk } from "../lib/queryKeys"; import { formatDate, formatDateTime, formatUnit } from "../lib/format"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, Download, KeyRound, Loader2, Monitor, RefreshCw, Trash2, User, } from "lucide-react"; import { DownloadFormats } from "./ProfileEditor"; import DiagnosticsSettings from "./DiagnosticsSettings"; import ApiTokens from "./ApiTokens"; import { type SettingsTabId } from "../lib/settingsTab"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, HttpError, type Me } from "../lib/api"; import { displayName, roleKey } from "../lib/api/account"; import { notifyActionError } from "../lib/actionErrors"; import { LS, useTabPersistedState } from "../lib/storage"; import Avatar from "./ui/Avatar"; import { notify, type NotifSettings } from "../lib/notifications"; import Tooltip from "./ui/Tooltip"; import { Section, SwitchRow } from "./ui/form"; import { Badge } from "./ui/Badge"; import { Button } from "./ui/Button"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./confirmContext"; import { StateMessage } from "./ui/QueryState"; import { useWizard } from "./wizardContext"; import { START_ON_HOME_OPTIONS, START_ON_HOME_PREF } from "./navigationInit"; import { usePref } from "../lib/usePref"; import { usePrefs } from "./prefsContext"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { useMe } from "../lib/useMe"; import { Pressable } from "./ui/Pressable"; import { Slider } from "./ui/NativeControls"; import { Input, Textarea } from "./ui/Input"; import { SURFACE } from "./ui/surfaces"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the // outcome. PrefsProvider owns the state + the save; this controller // is how the panel reads/writes it. export type PrefsSaveState = SaveState; export interface PrefsController { theme: ThemePrefs; setTheme: (t: ThemePrefs) => void; perf: boolean; setPerf: (v: boolean) => void; hints: boolean; setHints: (v: boolean) => void; railLabels: boolean; setRailLabels: (v: boolean) => void; notif: NotifSettings; setNotif: (n: NotifSettings) => void; saveState: PrefsSaveState; } type TabId = SettingsTabId; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, { id: "notifications", icon: Bell }, { id: "downloads", icon: Download }, { id: "tokens", icon: KeyRound }, { id: "account", icon: User }, { id: "diagnostics", icon: Activity }, ]; // 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() { const me = useMe(); const { t } = useTranslation(); // The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider. const prefs = usePrefs(); // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). const { openWizard } = useWizard(); const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance"); const panel = useTabPanel(); // 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 (
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see Tabs). Active is a clear accent pill. */} ({ id: tabItem.id, label: t(`settings.tabs.${tabItem.id}`), icon: tabItem.icon, testId: `settings-tab-${tabItem.id}`, }))} /> {/* 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 === "downloads" && } {tab === "tokens" && } {tab === "account" && } {tab === "diagnostics" && }
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the outcome (the Account tab's actions have their own feedback). */}
); } // A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the // bottom of the settings card and fades after a success (the state resets to "idle" in App). function SaveIndicator({ state }: { state: PrefsSaveState }) { const { t } = useTranslation(); if (state === "idle") return null; return (
{state === "saving" ? ( {t("settings.save.saving")} ) : state === "saved" ? ( {t("settings.save.saved")} ) : ( {t("settings.save.failed")} )}
); } function Appearance({ prefs }: { prefs: PrefsController }) { const { t } = useTranslation(); // Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced). const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs; // The landing page: read at boot (NavigationProvider), so a change applies from the next start. const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS); return ( <>
{/* One row whatever the count: a fifth scheme wrapped alone onto a second line. */}
{SCHEMES.map((s) => ( setTheme({ ...theme, scheme: s.id as Scheme })} aria-label={s.name} aria-pressed={theme.scheme === s.id} className={`h-9 w-full rounded-lg border-2 transition touch-target ${ theme.scheme === s.id ? "border-fg" : "border-transparent" }`} style={{ background: s.swatch }} /> ))}
setTheme({ ...theme, mode: v ? "dark" : "light" })} /> setTheme({ ...theme, bgImage: v })} /> {theme.bgImage && (
{t("settings.appearance.backgroundFade")} {theme.bgFade}%
setTheme({ ...theme, bgFade: v })} aria-label={t("settings.appearance.backgroundFade")} />
)}
setTheme({ ...theme, fontScale: v })} aria-label={t("settings.appearance.textSize")} />
{Math.round(theme.fontScale * 100)}%
); } function Notifications({ prefs }: { prefs: PrefsController }) { const { t, i18n } = useTranslation(); const lang = i18n.language; // Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves // it. The "send test" button below 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")} {formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
update({ durationMs: v })} aria-label={t("settings.notifications.dismissAfter")} />
)}
); } 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 signed-in account via /auth/google/connect); the password form posts // directly with inline errors. export function SignInMethods({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const [open, setOpen] = useState(false); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [sessBusy, setSessBusy] = useState(false); // SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind // device or a suspected compromise. const logoutOthers = async () => { if ( !(await confirm({ title: t("settings.account.sessions.confirmTitle"), message: t("settings.account.sessions.confirmBody"), confirmLabel: t("settings.account.sessions.action"), danger: true, })) ) return; setSessBusy(true); try { await api.logoutOthers(); // Every API token went with the other sessions: the Tokens tab asks again when it next opens, // rather than trusting its cached list for another half minute. void qc.invalidateQueries({ queryKey: qk.apiTokens() }); notify({ level: "success", message: t("settings.account.sessions.done") }); } catch (e) { notifyActionError(e, t("settings.account.sessions.failed")); } setSessBusy(false); }; const submit = async (e: React.SubmitEvent) => { e.preventDefault(); setErr(null); setBusy(true); const confirmCurrent = me.has_password ? current : undefined; try { await api.setPassword(next, confirmCurrent); setOpen(false); setCurrent(""); setNext(""); // Refresh `me` so has_password flips and the section switches to "Change password". Every API // token went with the old password, so the Tokens tab asks again when it next opens, too. void qc.invalidateQueries({ queryKey: qk.apiTokens() }); await qc.invalidateQueries({ queryKey: qk.me() }); notify({ level: "success", message: t("settings.account.password.saved", { email: me.email }), }); } catch (e) { setErr( (e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"), ); } setBusy(false); }; // 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")}

{/* Which address Google actually answers with — sent only when it differs from the account's own (see /api/me). "Connected" alone left that unanswerable, and it is exactly what someone who registered with a `+tag` needs to know. */} {me.google_email && (

{t("settings.account.googleLink.signedInAs")}{" "} {me.google_email}

)}
{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" /> )} setNext(e.target.value)} placeholder={t("settings.account.password.new")} autoComplete="new-password" /> {err && (

{err}

)}
)}
{t("settings.account.sessions.title")}

{t("settings.account.sessions.hint")}

); } // Two-way Plex watch-state sync (Phase A): the owner links their account to the Plex admin account // and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only // shown when the Plex module is enabled. Two-way push/pull arrive in later phases. function PlexWatchSync() { const { t, i18n } = useTranslation(); const lang = i18n.language; const qc = useQueryClient(); const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() }); const enabled = link.data?.sync_enabled ?? false; const announce = (imp: { watched: number; in_progress: number } | null | undefined) => { if (imp) { notify({ level: "success", message: t("settings.plexSync.imported", { watched: imp.watched, in_progress: imp.in_progress, }), }); // The browse grid's watch badges read plex_states — refresh them. qc.invalidateQueries({ queryKey: qk.plex() }); } }; // No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say // "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide // reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce. const toggle = useMutation({ mutationFn: (next: boolean) => api.plexWatchSetLink(next), meta: { failTitle: "errors.writeFailed.plexWatchLink" }, onSuccess: (res) => { qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); const reimport = useMutation({ mutationFn: () => api.plexWatchImport(), meta: { failTitle: "errors.writeFailed.plexWatchImport" }, onSuccess: (res) => { qc.setQueryData(qk.plexWatchLink(), res); announce(res.import); }, }); const busy = toggle.isPending || reimport.isPending; // Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than // only flipping once the multi-second import returns — otherwise it reads as "nothing happened". const shown = toggle.isPending ? Boolean(toggle.variables) : enabled; const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at, lang) : null; return (

{t("settings.plexSync.intro")}

!busy && toggle.mutate(v)} /> {busy ? (
{t("settings.plexSync.importing")}
) : ( enabled && (

{last ? t("settings.plexSync.lastSync", { when: last }) : t("settings.plexSync.never")}

) )}
); } // Per-user download authentication: store your own login cookies for an auth-walled host // (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only — // the stored blob is never shown back; each row shows only the host + whether it's set + when. // A cookie-authed download is kept private to your account (never cache-shared with another user). // Exported for a focused DOM test (write-only behaviour); rendered on the Downloads tab below. export function DownloadAuth() { const { t, i18n } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: qk.downloadCredentials(), queryFn: () => api.downloadCredentials(), }); const [openHost, setOpenHost] = useState(null); const [draft, setDraft] = useState(""); const save = useMutation({ mutationFn: ({ host, cookies }: { host: string; cookies: string }) => api.setDownloadCredential(host, cookies), onSuccess: (res) => { qc.setQueryData(qk.downloadCredentials(), res); setOpenHost(null); setDraft(""); notify({ level: "success", message: t("settings.downloadAuth.saved") }); }, // Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which // localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the // statuses it does not cover go to the app-wide reporter. meta: { failTitle: "settings.downloadAuth.failed" }, }); const remove = useMutation({ mutationFn: (host: string) => api.deleteDownloadCredential(host), meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" }, onSuccess: (res) => { qc.setQueryData(qk.downloadCredentials(), res); notify({ level: "success", message: t("settings.downloadAuth.removed") }); }, }); const hosts = q.data?.hosts ?? []; const onRemove = async (host: string) => { const ok = await confirm({ title: t("settings.downloadAuth.removeConfirmTitle"), message: t("settings.downloadAuth.removeConfirmBody"), confirmLabel: t("settings.downloadAuth.removeConfirmAction"), danger: true, }); if (ok) remove.mutate(host); }; return (

{t("settings.downloadAuth.intro")}

{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means the fetch failed or is still loading — render an honest state, never a bare empty section that reads as "nothing to configure here". */} {q.isError && hosts.length === 0 ? ( q.refetch()}> {t("settings.downloadAuth.loadError")} ) : q.isLoading ? ( {t("common.loading")} ) : (
{hosts.map((h) => { const when = h.updated_at ? formatDate(h.updated_at, i18n.language) : ""; const open = openHost === h.host_key; return (
{h.label}

{h.configured ? t("settings.downloadAuth.configured", { when }) : t("settings.downloadAuth.notSet")}

{h.configured && ( )}
{open && (
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never fetched or prefilled. */}