siftlode/frontend/src/components/SettingsPanel.tsx

569 lines
20 KiB
TypeScript
Raw Normal View History

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
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 },
];
// Persist the active tab so a reload (F5) keeps the user where they were instead of
// snapping back to "appearance".
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
function loadSettingsTab(): TabId {
const v = localStorage.getItem(SETTINGS_TAB_KEY);
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
}
// 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 [tab, setTabState] = useState<TabId>(loadSettingsTab);
const setTab = (id: TabId) => {
setTabState(id);
localStorage.setItem(SETTINGS_TAB_KEY, id);
};
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();
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
if (!prefs.dirty && prefs.saveState === "idle") return null;
const saving = prefs.saveState === "saving";
return (
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
<span className="text-sm text-muted">
{prefs.saveState === "saved"
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
: prefs.saveState === "error"
? <span className="text-red-500">{t("settings.save.failed")}</span>
: t("settings.save.unsaved")}
</span>
<div className="flex items-center gap-2">
<button
onClick={prefs.discard}
disabled={saving || !prefs.dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("settings.save.discard")}
</button>
<button
onClick={prefs.save}
disabled={saving || !prefs.dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saving ? t("settings.save.saving") : t("settings.save.save")}
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
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 })}
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")}>
<Row label={t("settings.appearance.darkMode")}>
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch checked={perf} onChange={setPerf} />
</Row>
<Row
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch checked={hints} onChange={setHints} />
</Row>
</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) })}
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")}>
<Row
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
{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) })}
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";
return (
<Section title={t("settings.account.signInMethods")}>
<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="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>
);
}
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.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">
{t("settings.account.demoNotice")}
</p>
</Section>
) : (
<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>
)}
{!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>
)}
</>
);
}