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.) */}
{/* 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 (
);
}
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. */}
);
}
// 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 && (
{/* 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 && (
)
)}
);
}
// 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. */}
)}
);
})}
)}
);
}
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
* not recognise is something they can see. */
export function AccountHeader({ me }: { me: Me }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
return (
{displayName(me)}
{me.email}
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
);
}
// How this account downloads: its formats (the download dialog's choices) and its sign-ins to sites
// that need one. Preferences of the downloading, not of the account's identity.
function Downloads() {
const { t } = useTranslation();
return (
<>
{t("settings.downloads.formatsIntro")}
>
);
}
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 (e) {
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
// the reporter skips; anything else used to end the confirmed delete with nothing said.
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
}
};
return (
<>
{me.role === "admin" && me.plex_enabled && }
{me.google_enabled ? (
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.