The watch-sync switch only flipped once the (multi-second) import request returned, so a click read as 'nothing happened' and invited repeat clicks. Reflect the toggle's target optimistically while the mutation is in flight and show an 'Importing your Plex watch history…' spinner line, so it's obvious the switch responded and is working.
602 lines
22 KiB
TypeScript
602 lines
22 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|
import { api, type Me } from "../lib/api";
|
|
import { LS, useAccountPersistedState } 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] = 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. */}
|
|
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
|
{TABS.map((tabItem) => {
|
|
const active = tab === tabItem.id;
|
|
return (
|
|
<button
|
|
key={tabItem.id}
|
|
onClick={() => setTab(tabItem.id)}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
|
active
|
|
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
|
: "text-muted hover:text-fg hover:bg-card/60"
|
|
}`}
|
|
>
|
|
<tabItem.icon className="w-4 h-4 shrink-0" />
|
|
{t(`settings.tabs.${tabItem.id}`)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* 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={onOpenWizard} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<PrefsSaveBar prefs={prefs} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<DraftSaveBar
|
|
variant="inline"
|
|
dirty={prefs.dirty}
|
|
state={prefs.saveState}
|
|
onSave={prefs.save}
|
|
onDiscard={prefs.discard}
|
|
labels={{
|
|
saved: t("settings.save.saved"),
|
|
failed: t("settings.save.failed"),
|
|
unsaved: t("settings.save.unsaved"),
|
|
discard: t("settings.save.discard"),
|
|
save: t("settings.save.save"),
|
|
saving: t("settings.save.saving"),
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<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.listView")} hint={t("settings.appearance.listViewHint")}>
|
|
<Switch
|
|
label={t("settings.appearance.listView")}
|
|
checked={view === "list"}
|
|
onChange={(v) => setView(v ? "list" : "grid")}
|
|
/>
|
|
</SettingRow>
|
|
<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>
|
|
</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-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.granted")}
|
|
</span>
|
|
) : (
|
|
<Tooltip hint={t("settings.account.enableHint")}>
|
|
<button
|
|
onClick={onEnable}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{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 [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 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 (
|
|
<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>
|
|
</div>
|
|
{me.has_google ? (
|
|
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.googleLink.connected")}
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
window.location.href = "/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-red-400">{err}</p>}
|
|
<button
|
|
type="submit"
|
|
disabled={busy || !next}
|
|
className="px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
|
|
>
|
|
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</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: ["plex-watch-link"], 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: ["plex"] });
|
|
}
|
|
};
|
|
|
|
const toggle = useMutation({
|
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(["plex-watch-link"], res);
|
|
announce(res.import);
|
|
},
|
|
});
|
|
const reimport = useMutation({
|
|
mutationFn: () => api.plexWatchImport(),
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(["plex-watch-link"], 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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<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 ? (
|
|
<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 = "/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 = "/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
|
|
onClick={onDeleteAccount}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-red-500/40 text-red-400 hover:bg-red-500/10 transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("settings.account.deleteAccount")}
|
|
</button>
|
|
</Section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|