Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped), plus the admin refusal copy gap round 1 surfaced. - A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link). - Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists. - The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after new results starts at the first visible item; a menu or dialog opened from the item keeps it. - The list keys reach the cheat sheet only while a list has an item. - A j-focused card root is a named group while it holds the focus. - The channel page's Block and Hide toggles keep the focus while their request runs. - A split export that fails part-way says how many clips were added and deselects them, so a retry adds only the rest. - Config save, the one-click share link and "log out other sessions" report failures with their reason. - VirtualGrid uses the one scroll-parent walk listNav measures against. - Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again"; the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail. HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
871 lines
34 KiB
TypeScript
871 lines
34 KiB
TypeScript
import { useState } from "react";
|
|
import { withActiveAccount } from "../lib/activeAccount";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|
import { api, HttpError, type Me } from "../lib/api";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
|
import Avatar from "./ui/Avatar";
|
|
import { notify, type NotifSettings } from "../lib/notifications";
|
|
import Tooltip from "./ui/Tooltip";
|
|
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
|
import { Button } from "./ui/Button";
|
|
import { type SaveState } from "./ui/DraftSaveBar";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import { StateMessage } from "./ui/QueryState";
|
|
import { useWizard } from "./WizardProvider";
|
|
import { START_ON_HOME } from "./navigationInit";
|
|
import { usePrefs } from "./PrefsProvider";
|
|
import Tabs from "./ui/Tabs";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
// 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. App owns the state + the save; this controller is
|
|
// how the panel reads/writes it. See App.tsx.
|
|
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;
|
|
notif: NotifSettings;
|
|
setNotif: (n: NotifSettings) => 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() {
|
|
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] = useAccountPersistedState(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 (
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
<div className="glass rounded-2xl overflow-hidden">
|
|
<div className="flex">
|
|
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
|
<Tabs
|
|
orientation="vertical"
|
|
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
|
|
active={tab}
|
|
onChange={setTab}
|
|
tabs={TABS.map((tabItem) => ({
|
|
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.) */}
|
|
<div className="flex-1 min-w-0 p-4">
|
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
|
|
outcome (the Account tab's actions have their own feedback). */}
|
|
<SaveIndicator state={prefs.saveState} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div
|
|
data-testid="settings-save-indicator"
|
|
data-state={state}
|
|
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
|
|
>
|
|
{state === "saving" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-muted">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t("settings.save.saving")}
|
|
</span>
|
|
) : state === "saved" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-success">
|
|
<Check className="w-4 h-4" />
|
|
{t("settings.save.saved")}
|
|
</span>
|
|
) : (
|
|
<span className="text-danger">{t("settings.save.failed")}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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, perf, setPerf, hints, setHints } = prefs;
|
|
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
|
|
// has to answer before /api/me lands, so the value must be readable synchronously from
|
|
// localStorage — a landing module decided after the first paint would be a visible jump. The two
|
|
// stored values come from navigationInit, which is what reads them back at boot.
|
|
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("settings.appearance.colorScheme")}>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{SCHEMES.map((s) => (
|
|
<Tooltip key={s.id} hint={s.name}>
|
|
<button
|
|
onClick={() => 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 ${
|
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
|
}`}
|
|
style={{ background: s.swatch }}
|
|
/>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.display")}>
|
|
<SettingRow label={t("settings.appearance.darkMode")}>
|
|
<Switch
|
|
label={t("settings.appearance.darkMode")}
|
|
checked={theme.mode === "dark"}
|
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.backgroundImage")}
|
|
hint={t("settings.appearance.backgroundImageHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.backgroundImage")}
|
|
checked={theme.bgImage}
|
|
onChange={(v) => setTheme({ ...theme, bgImage: v })}
|
|
/>
|
|
</SettingRow>
|
|
{theme.bgImage && (
|
|
<div className="pl-1 pb-1">
|
|
<div className="flex items-center justify-between text-xs text-muted mb-1">
|
|
<span>{t("settings.appearance.backgroundFade")}</span>
|
|
<span className="tabular-nums">{theme.bgFade}%</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={theme.bgFade}
|
|
onChange={(e) => setTheme({ ...theme, bgFade: Number(e.target.value) })}
|
|
aria-label={t("settings.appearance.backgroundFade")}
|
|
className="w-full accent-accent"
|
|
/>
|
|
</div>
|
|
)}
|
|
<SettingRow
|
|
label={t("settings.appearance.performanceMode")}
|
|
hint={t("settings.appearance.performanceModeHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.performanceMode")}
|
|
checked={perf}
|
|
onChange={setPerf}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.showHints")}
|
|
hint={t("settings.appearance.showHintsHint")}
|
|
>
|
|
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.showTuner")}
|
|
hint={t("settings.appearance.showTunerHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.showTuner")}
|
|
checked={theme.showTuner}
|
|
onChange={(v) => setTheme({ ...theme, showTuner: v })}
|
|
/>
|
|
</SettingRow>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.startup")}>
|
|
<SettingRow
|
|
label={t("settings.appearance.startOnHome")}
|
|
hint={t("settings.appearance.startOnHomeHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.startOnHome")}
|
|
checked={startOnHome !== START_ON_HOME.off}
|
|
onChange={(v) => setStartOnHome(v ? START_ON_HOME.on : START_ON_HOME.off)}
|
|
/>
|
|
</SettingRow>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.textSize")}>
|
|
<input
|
|
type="range"
|
|
min={0.9}
|
|
max={1.3}
|
|
step={0.02}
|
|
value={theme.fontScale}
|
|
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
|
|
aria-label={t("settings.appearance.textSize")}
|
|
className="w-full accent-accent"
|
|
/>
|
|
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<NotifSettings>) => setNotif({ ...cfg, ...p });
|
|
const autoOn = cfg.durationMs > 0;
|
|
|
|
return (
|
|
<Section title={t("settings.notifications.title")}>
|
|
<SettingRow
|
|
label={t("settings.notifications.sound")}
|
|
hint={t("settings.notifications.soundHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.notifications.sound")}
|
|
checked={cfg.sound}
|
|
onChange={(v) => update({ sound: v })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.notifications.autoDismiss")}
|
|
hint={t("settings.notifications.autoDismissHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.notifications.autoDismiss")}
|
|
checked={autoOn}
|
|
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
|
|
/>
|
|
</SettingRow>
|
|
{autoOn && (
|
|
<div className="py-1.5 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
|
|
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={2000}
|
|
max={15000}
|
|
step={1000}
|
|
value={cfg.durationMs}
|
|
onChange={(e) => update({ durationMs: Number(e.target.value) })}
|
|
aria-label={t("settings.notifications.dismissAfter")}
|
|
className="w-full accent-accent mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={() =>
|
|
notify({
|
|
level: "info",
|
|
title: t("settings.notifications.testTitle"),
|
|
message: t("settings.notifications.testMessage"),
|
|
sound: true,
|
|
})
|
|
}
|
|
className="mt-2 text-sm text-accent hover:underline"
|
|
>
|
|
{t("settings.notifications.sendTest")}
|
|
</button>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function AccessRow({
|
|
title,
|
|
granted,
|
|
grantedHint,
|
|
enableHint,
|
|
onEnable,
|
|
}: {
|
|
title: string;
|
|
granted: boolean;
|
|
grantedHint: string;
|
|
enableHint: string;
|
|
onEnable: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{title}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{granted ? grantedHint : enableHint}
|
|
</p>
|
|
</div>
|
|
{granted ? (
|
|
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.granted")}
|
|
</span>
|
|
) : (
|
|
<Tooltip hint={t("settings.account.enableHint")}>
|
|
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
|
{t("settings.account.enable")}
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 confirm = useConfirm();
|
|
const [open, setOpen] = useState(false);
|
|
const [current, setCurrent] = useState("");
|
|
const [next, setNext] = useState("");
|
|
const [err, setErr] = useState<string | null>(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();
|
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
|
} catch (e) {
|
|
notifyActionError(e, t("settings.account.sessions.failed"));
|
|
} finally {
|
|
setSessBusy(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: 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"),
|
|
);
|
|
} finally {
|
|
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 (
|
|
<Section title={t("settings.account.signInMethods")}>
|
|
{showGoogle && (
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{me.has_google
|
|
? t("settings.account.googleLink.connectedHint")
|
|
: t("settings.account.googleLink.connectHint")}
|
|
</p>
|
|
{/* 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 && (
|
|
<p className="text-xs text-fg/80 mt-1.5">
|
|
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
|
|
{me.google_email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{me.has_google ? (
|
|
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.googleLink.connected")}
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
window.location.href = withActiveAccount("/auth/link");
|
|
}}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{t("settings.account.googleLink.connect")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={showGoogle ? "border-t border-border mt-1 pt-1" : ""}>
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{me.has_password
|
|
? t("settings.account.password.setHint")
|
|
: t("settings.account.password.unsetHint")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setOpen((o) => !o);
|
|
setErr(null);
|
|
}}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{me.has_password
|
|
? t("settings.account.password.change")
|
|
: t("settings.account.password.set")}
|
|
</button>
|
|
</div>
|
|
{open && (
|
|
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
|
|
{me.has_password && (
|
|
<input
|
|
type="password"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
placeholder={t("settings.account.password.current")}
|
|
autoComplete="current-password"
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
<input
|
|
type="password"
|
|
value={next}
|
|
onChange={(e) => setNext(e.target.value)}
|
|
placeholder={t("settings.account.password.new")}
|
|
autoComplete="new-password"
|
|
className={inputCls}
|
|
/>
|
|
{err && <p className="text-xs text-danger">{err}</p>}
|
|
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
|
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border mt-1 pt-1">
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.sessions.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{t("settings.account.sessions.hint")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="glass"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={logoutOthers}
|
|
disabled={sessBusy}
|
|
>
|
|
{t("settings.account.sessions.action")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// 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 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
|
|
? new Date(link.data.last_watch_sync_at).toLocaleString(i18n.language)
|
|
: null;
|
|
|
|
return (
|
|
<Section title={t("settings.plexSync.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
|
|
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
|
|
<Switch checked={shown} disabled={busy} onChange={(v) => !busy && toggle.mutate(v)} />
|
|
</SettingRow>
|
|
{busy ? (
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
{t("settings.plexSync.importing")}
|
|
</div>
|
|
) : (
|
|
enabled && (
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted">
|
|
{last
|
|
? t("settings.plexSync.lastSync", { when: last })
|
|
: t("settings.plexSync.never")}
|
|
</p>
|
|
<button
|
|
onClick={() => reimport.mutate()}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-border hover:bg-card/60 transition shrink-0"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
{t("settings.plexSync.importNow")}
|
|
</button>
|
|
</div>
|
|
)
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// Per-user download authentication (S6): 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 inside Account 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<string | null>(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 (
|
|
<Section title={t("settings.downloadAuth.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
|
|
{/* 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 ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("settings.downloadAuth.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<StateMessage>{t("common.loading")}</StateMessage>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{hosts.map((h) => {
|
|
const when = h.updated_at
|
|
? new Date(h.updated_at).toLocaleDateString(i18n.language)
|
|
: "";
|
|
const open = openHost === h.host_key;
|
|
return (
|
|
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{h.label}</div>
|
|
<p
|
|
className="text-xs text-muted mt-0.5"
|
|
data-testid={`cookie-status-${h.host_key}`}
|
|
>
|
|
{h.configured
|
|
? t("settings.downloadAuth.configured", { when })
|
|
: t("settings.downloadAuth.notSet")}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{h.configured && (
|
|
<Button
|
|
variant="danger-soft"
|
|
size="sm"
|
|
onClick={() => onRemove(h.host_key)}
|
|
disabled={remove.isPending}
|
|
>
|
|
{t("settings.downloadAuth.remove")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpenHost(open ? null : h.host_key);
|
|
setDraft("");
|
|
}}
|
|
>
|
|
{h.configured
|
|
? t("settings.downloadAuth.update")
|
|
: t("settings.downloadAuth.set")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{open && (
|
|
<div className="mt-2 space-y-2">
|
|
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
|
fetched or prefilled. */}
|
|
<textarea
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
rows={5}
|
|
placeholder={t("settings.downloadAuth.placeholder")}
|
|
className={`${inputCls} font-mono text-xs resize-y`}
|
|
data-testid={`cookie-input-${h.host_key}`}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
<p className="text-2xs text-muted leading-relaxed">
|
|
{t("settings.downloadAuth.help")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={!draft.trim() || save.isPending}
|
|
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
|
>
|
|
{save.isPending
|
|
? t("settings.downloadAuth.saving")
|
|
: t("settings.downloadAuth.save")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpenHost(null);
|
|
setDraft("");
|
|
}}
|
|
>
|
|
{t("settings.downloadAuth.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<Section title={t("settings.account.title")}>
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<Avatar
|
|
src={me.avatar_url}
|
|
fallback={me.display_name ?? me.email}
|
|
className="w-12 h-12 rounded-full"
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="font-semibold truncate">
|
|
{me.display_name ?? me.email.split("@")[0]}
|
|
</div>
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
<div className="text-xs text-muted capitalize">{me.role}</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{!me.is_demo && <SignInMethods me={me} />}
|
|
|
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
|
|
|
{!me.is_demo && <DownloadAuth />}
|
|
|
|
{me.is_demo ? (
|
|
<Section title={t("settings.account.youtubeAccess")}>
|
|
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
|
</Section>
|
|
) : me.google_enabled ? (
|
|
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
|
<Section title={t("settings.account.youtubeAccess")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
|
{t("settings.account.youtubeAccessIntro")}
|
|
</p>
|
|
<div className="divide-y divide-border">
|
|
<AccessRow
|
|
title={t("settings.account.readTitle")}
|
|
granted={me.can_read}
|
|
grantedHint={t("settings.account.readGrantedHint")}
|
|
enableHint={t("settings.account.readEnableHint")}
|
|
onEnable={() => {
|
|
window.location.href = withActiveAccount("/auth/upgrade?access=read");
|
|
}}
|
|
/>
|
|
<AccessRow
|
|
title={t("settings.account.writeTitle")}
|
|
granted={me.can_write}
|
|
grantedHint={t("settings.account.writeGrantedHint")}
|
|
enableHint={t("settings.account.writeEnableHint")}
|
|
onEnable={() => {
|
|
window.location.href = withActiveAccount("/auth/upgrade?access=write");
|
|
}}
|
|
/>
|
|
</div>
|
|
<button onClick={onOpenWizard} className="mt-2 text-sm text-accent hover:underline">
|
|
{t("settings.account.walkMeThrough")}
|
|
</button>
|
|
</Section>
|
|
) : null}
|
|
|
|
{!me.is_demo && (
|
|
<Section title={t("settings.account.dangerZone")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">
|
|
{t("settings.account.deleteHint")}
|
|
</p>
|
|
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("settings.account.deleteAccount")}
|
|
</Button>
|
|
</Section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|