import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Invite, type Me } from "../lib/api"; import { formatEta, quotaActionLabel } from "../lib/format"; import Avatar from "./Avatar"; import { configureNotifications, getNotifSettings, notify, type NotifSettings, } from "../lib/notifications"; import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; import Tooltip from "./Tooltip"; import { useConfirm } from "./ConfirmProvider"; type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; icon: typeof Monitor }[] = [ { id: "appearance", icon: Monitor }, { id: "notifications", icon: Bell }, { id: "sync", icon: RefreshCw }, { 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, theme, setTheme, view, setView, onOpenWizard, }: { me: Me; theme: ThemePrefs; setTheme: (t: ThemePrefs) => void; view: "grid" | "list"; setView: (v: "grid" | "list") => void; onOpenWizard: () => void; }) { const { t } = useTranslation(); const [tab, setTab] = useState("appearance"); return (
{/* Vertical tab rail — never wraps; active is a clear accent pill. */} {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab (stable height, no jump on switch); the active one is shown on top. */}
{TABS.map((tabItem) => (
{tabItem.id === "appearance" && ( )} {tabItem.id === "notifications" && } {tabItem.id === "sync" && } {tabItem.id === "account" && }
))}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } function Row({ label, hint, children, }: { label: string; hint?: string; children: React.ReactNode; }) { return (
{label} {children}
); } function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ); } const PERF_KEY = "subfeed.perfMode"; function Appearance({ theme, setTheme, view, setView, }: { theme: ThemePrefs; setTheme: (t: ThemePrefs) => void; view: "grid" | "list"; setView: (v: "grid" | "list") => void; }) { const { t } = useTranslation(); const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); useEffect(() => { document.documentElement.dataset.perf = perf ? "1" : ""; }, [perf]); function togglePerf(v: boolean) { setPerf(v); localStorage.setItem(PERF_KEY, v ? "1" : "0"); api.savePrefs({ performanceMode: v }).catch(() => {}); } function toggleHints(v: boolean) { setHintsEnabled(v); api.savePrefs({ hints: v }).catch(() => {}); } return ( <>
{SCHEMES.map((s) => (
setTheme({ ...theme, mode: v ? "dark" : "light" })} /> setView(v ? "list" : "grid")} />
setTheme({ ...theme, fontScale: Number(e.target.value) })} className="w-full accent-accent" />
{Math.round(theme.fontScale * 100)}%
); } function Notifications() { const { t } = useTranslation(); const [cfg, setCfg] = useState(() => getNotifSettings()); function update(p: Partial) { const next = { ...cfg, ...p }; setCfg(next); configureNotifications(next); api.savePrefs({ notifications: next }).catch(() => {}); } const autoOn = cfg.durationMs > 0; return (
update({ sound: v })} /> update({ durationMs: v ? 6000 : 0 })} /> {autoOn && (
{t("settings.notifications.dismissAfter")} {(cfg.durationMs / 1000).toFixed(0)}s
update({ durationMs: Number(e.target.value) })} className="w-full accent-accent mt-1" />
)}
); } function Sync({ me }: { me: Me }) { const { t } = useTranslation(); const qc = useQueryClient(); const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); const s = status.data; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["channels"] }); notify({ level: "success", message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }), }); }, onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }), }); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["channels"] }); notify({ level: "success", message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }), }); return ( <>
{s ? (
{s.channels_total} {`${s.channels_recent_synced}/${s.channels_total}`} {`${s.channels_deep_done}/${s.channels_deep_requested}`} {s.deep_pending_count > 0 && ( {formatEta(s.deep_eta_seconds)} )} {s.my_videos.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
) : (
{t("settings.sync.loading")}
)}
{usage.data ? ( <>
{usage.data.today.toLocaleString()} {usage.data.last_7d.toLocaleString()} {usage.data.last_30d.toLocaleString()} {usage.data.all_time.toLocaleString()}
{Object.keys(usage.data.by_action).length > 0 && (
{t("settings.sync.byAction")}
{Object.entries(usage.data.by_action) .sort((a, b) => b[1] - a[1]) .map(([action, units]) => (
{quotaActionLabel(action)} {units.toLocaleString()}
))}
)} ) : (
{t("settings.sync.noUsage")}
)}
{!me.is_demo && (
)} {me.role === "admin" && s && (
)} ); } 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")} ) : ( )}
); } function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { const { t } = useTranslation(); return ( <>
{me.display_name ?? me.email.split("@")[0]}
{me.email}
{me.role}
{me.is_demo ? (

{t("settings.account.demoNotice")}

) : (

{t("settings.account.youtubeAccessIntro")}

{ window.location.href = "/auth/upgrade?access=read"; }} /> { window.location.href = "/auth/upgrade?access=write"; }} />
)} {me.role === "admin" && } {me.role === "admin" && } ); } function AdminInvites() { const { t } = useTranslation(); const qc = useQueryClient(); const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() }); const [newEmail, setNewEmail] = useState(""); const refresh = () => { qc.invalidateQueries({ queryKey: ["admin-invites"] }); qc.invalidateQueries({ queryKey: ["me"] }); // updates the pending badge }; const approve = useMutation({ mutationFn: (id: number) => api.approveInvite(id), onSuccess: () => { refresh(); notify({ level: "success", message: t("settings.invites.approved") }); }, onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }), }); const deny = useMutation({ mutationFn: (id: number) => api.denyInvite(id), onSuccess: refresh, onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }), }); const add = useMutation({ mutationFn: (email: string) => api.addInvite(email), onSuccess: () => { setNewEmail(""); refresh(); notify({ level: "success", message: t("settings.invites.addedToWhitelist") }); }, onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }), }); const list = invites.data ?? []; const pending = list.filter((i) => i.status === "pending"); const decided = list.filter((i) => i.status !== "pending"); return (
{pending.length === 0 ? (

{t("settings.invites.noPending")}

) : (
{pending.map((i) => ( approve.mutate(i.id)} onDeny={() => deny.mutate(i.id)} busy={approve.isPending || deny.isPending} /> ))}
)}
{ e.preventDefault(); if (newEmail.trim()) add.mutate(newEmail.trim()); }} className="flex items-center gap-2 mt-3" > setNewEmail(e.target.value)} placeholder={t("settings.invites.addPlaceholder")} className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" />
{decided.length > 0 && (
{t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => (
{i.email} {i.status}
))}
)}
); } function AdminDemo() { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const list = useQuery({ queryKey: ["demo-whitelist"], queryFn: () => api.adminDemoWhitelist() }); const [newEmail, setNewEmail] = useState(""); const add = useMutation({ mutationFn: (email: string) => api.addDemoWhitelist(email), onSuccess: () => { setNewEmail(""); qc.invalidateQueries({ queryKey: ["demo-whitelist"] }); notify({ level: "success", message: t("settings.demo.added") }); }, onError: () => notify({ level: "error", message: t("settings.demo.addFailed") }), }); const remove = useMutation({ mutationFn: (id: number) => api.removeDemoWhitelist(id), onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }), onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }), }); const reset = useMutation({ mutationFn: () => api.resetDemo(), onSuccess: (r: { playlists_seeded: number }) => notify({ level: "success", message: t("settings.demo.resetDone", { count: r.playlists_seeded }), }), onError: () => notify({ level: "error", message: t("settings.demo.resetFailed") }), }); const rows = list.data ?? []; return (

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

{rows.length > 0 && (
{rows.map((r) => (
{r.email}
{r.added_by && (
{t("settings.demo.addedBy", { who: r.added_by })}
)}
))}
)}
{ e.preventDefault(); if (newEmail.trim()) add.mutate(newEmail.trim()); }} className="flex items-center gap-2" > setNewEmail(e.target.value)} placeholder={t("settings.demo.addPlaceholder")} className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" />
); } function InviteRow({ inv, onApprove, onDeny, busy, }: { inv: Invite; onApprove: () => void; onDeny: () => void; busy: boolean; }) { const { t } = useTranslation(); return (
{inv.email}
{inv.requested_at && (
{t("settings.invites.requested", { time: new Date(inv.requested_at).toLocaleString(), })}
)}
); }