import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, History, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { api, type Me } from "../lib/api"; import { formatEta } from "../lib/format"; import { configureNotifications, getNotifSettings, notify, type NotifSettings, } from "../lib/notifications"; import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; import Tooltip from "./Tooltip"; type TabId = "appearance" | "notifications" | "sync" | "account"; const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [ { id: "appearance", label: "Appearance", icon: Monitor }, { id: "notifications", label: "Notifications", icon: Bell }, { id: "sync", label: "Sync", icon: RefreshCw }, { id: "account", label: "Account", icon: User }, ]; export default function SettingsPanel({ me, theme, setTheme, view, setView, onClose, }: { me: Me; theme: ThemePrefs; setTheme: (t: ThemePrefs) => void; view: "grid" | "list"; setView: (v: "grid" | "list") => void; onClose: () => void; }) { const [tab, setTab] = useState("appearance"); const [closing, setClosing] = useState(false); const close = useCallback(() => { setClosing(true); setTimeout(onClose, 190); }, [onClose]); useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); } document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [close]); return (
Settings
{/* 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((t) => (
{t.id === "appearance" && ( )} {t.id === "notifications" && } {t.id === "sync" && } {t.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 [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 [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 && (
Dismiss after {(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 qc = useQueryClient(); const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); 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: `Synced ${r.subscriptions ?? 0} subscriptions` }); }, onError: () => notify({ level: "error", message: "Sync failed" }), }); 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: `Full history requested for ${r.updated ?? 0} channels`, }); }, onError: () => notify({ level: "error", message: "Couldn't request full history" }), }); 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()}
) : (
Loading…
)}
{me.role === "admin" && s && (
)} ); } function Account({ me }: { me: Me }) { return (
{me.avatar_url ? ( ) : (
{(me.display_name?.[0] ?? me.email[0] ?? "?").toUpperCase()}
)}
{me.display_name ?? me.email.split("@")[0]}
{me.email}
{me.role}
Playlist editing & YouTube export

{me.can_write ? "Granted. You can unsubscribe from channels on YouTube (and export playlists once that ships). Subfeed only writes when you ask it to." : "Subfeed is read-only by default — it can browse your subscriptions but not change your YouTube account. Enable this to unsubscribe from channels (and, later, export playlists). You'll re-consent with Google."}

{me.can_write ? ( Enabled ) : ( )}
); }