siftlode/frontend/src/components/SettingsPanel.tsx
npeter83 93bd805089 fix(ui): robust avatars — no-referrer + graceful fallback
Channel/account avatars come from Google's image CDNs (yt3.ggpht.com,
lh3.googleusercontent.com). On a feed page dozens load at once; the CDN
rate-limits the referrer-bearing burst (429), so a random subset rendered the
browser's broken-image icon (the URLs themselves are valid — verified 200).

Add a shared <Avatar> that sets referrerPolicy="no-referrer" (which the CDNs
serve without throttling) and falls back to a neutral initial placeholder on
error instead of the broken-image icon. Use it for video-card, player, channel
manager, header and settings avatars.
2026-06-12 18:01:43 +02:00

656 lines
23 KiB
TypeScript

import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, 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";
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<TabId>("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 (
<div className="fixed inset-0 z-40 flex justify-end">
<div
className={`absolute inset-0 bg-black/50 ${
closing ? "animate-[overlayOut_0.19s_ease_forwards]" : "animate-[overlayIn_0.2s_ease]"
}`}
onClick={close}
/>
<div
className={`glass relative self-start m-3 w-[min(94vw,520px)] max-h-[calc(100vh-1.5rem)] rounded-2xl overflow-hidden flex flex-col ${
closing
? "animate-[panelOut_0.19s_ease-in_forwards]"
: "animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-border/60 shrink-0">
<div className="text-base font-semibold">Settings</div>
<button
onClick={close}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-1 min-h-0">
{/* 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((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.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"
}`}
>
<t.icon className="w-4 h-4 shrink-0" />
{t.label}
</button>
);
})}
</nav>
{/* 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. */}
<div className="grid flex-1 overflow-y-auto">
{TABS.map((t) => (
<div
key={t.id}
className={`[grid-area:1/1] p-4 ${
tab === t.id ? "" : "invisible pointer-events-none"
}`}
>
{t.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} />}
</div>
))}
</div>
</div>
</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>
);
}
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 (
<>
<Section title="Color scheme">
<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="Display">
<Row label="Dark mode">
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view" hint="Show the feed as a compact list instead of a grid of cards.">
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label="Performance mode"
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
>
<Switch checked={perf} onChange={togglePerf} />
</Row>
<Row
label="Show hints"
hint="These little hover explanations. Turn them off once you know your way around."
>
<Switch checked={hints} onChange={toggleHints} />
</Row>
</Section>
<Section title="Text size">
<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() {
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
function update(p: Partial<NotifSettings>) {
const next = { ...cfg, ...p };
setCfg(next);
configureNotifications(next);
api.savePrefs({ notifications: next }).catch(() => {});
}
const autoOn = cfg.durationMs > 0;
return (
<Section title="Notifications">
<Row
label="Sound on alerts"
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label="Auto-dismiss toasts"
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
>
<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">Dismiss after</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: "Test notification",
message: "This is what a notification looks like.",
sound: true,
})
}
className="mt-2 text-sm text-accent hover:underline"
>
Send a test notification
</button>
</Section>
);
}
function Sync({ me }: { me: Me }) {
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: `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 (
<>
<Section title="My sync status">
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels" hint="How many channels you're subscribed to.">
{s.channels_total}
</Row>
<Row
label="Recent synced"
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label="Full history"
hint="Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page)."
>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label="Full history ETA"
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label="My videos" hint="Total videos available to you across your subscribed channels.">
{s.my_videos.toLocaleString()}
</Row>
<Row
label="Quota left today"
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
)}
</Section>
<Section title="Your API usage">
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
{usage.data.today.toLocaleString()}
</Row>
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">By action (30d)</div>
{Object.entries(usage.data.by_action)
.sort((a, b) => b[1] - a[1])
.map(([action, units]) => (
<div key={action} className="flex justify-between gap-2">
<span>{quotaActionLabel(action)}</span>
<span className="tabular-nums">{units.toLocaleString()}</span>
</div>
))}
</div>
)}
</>
) : (
<div className="text-muted text-sm">No usage yet.</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
</Tooltip>
<Tooltip hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.">
<button
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
Backfill everything
</button>
</Tooltip>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
</button>
</Tooltip>
</Section>
)}
</>
);
}
function Account({ me }: { me: Me }) {
return (
<>
<Section title="Account">
<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>
<div className="mt-1 pt-3 border-t border-border">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">Playlist editing &amp; YouTube export</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{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."}
</p>
</div>
{me.can_write ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
Enabled
</span>
) : (
<Tooltip hint="Redirects to Google to grant YouTube write access (unsubscribe / export). You can keep using Subfeed read-only if you skip it.">
<button
onClick={() => {
window.location.href = "/auth/upgrade";
}}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
Enable
</button>
</Tooltip>
)}
</div>
</div>
</Section>
{me.role === "admin" && <AdminInvites />}
</>
);
}
function AdminInvites() {
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: "Approved — they can sign in now" });
},
onError: () => notify({ level: "error", message: "Approve failed" }),
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onError: () => notify({ level: "error", message: "Deny failed" }),
});
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: () => {
setNewEmail("");
refresh();
notify({ level: "success", message: "Added to the whitelist" });
},
onError: () => notify({ level: "error", message: "Couldn't add that email" }),
});
const list = invites.data ?? [];
const pending = list.filter((i) => i.status === "pending");
const decided = list.filter((i) => i.status !== "pending");
return (
<Section title="Access requests">
{pending.length === 0 ? (
<p className="text-sm text-muted">No pending requests.</p>
) : (
<div className="space-y-1.5">
{pending.map((i) => (
<InviteRow
key={i.id}
inv={i}
onApprove={() => approve.mutate(i.id)}
onDeny={() => deny.mutate(i.id)}
busy={approve.isPending || deny.isPending}
/>
))}
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (newEmail.trim()) add.mutate(newEmail.trim());
}}
className="flex items-center gap-2 mt-3"
>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="Add an email directly…"
className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<UserPlus className="w-4 h-4" /> Add
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{decided.length} decided
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
<div key={i.id} className="flex items-center justify-between gap-2">
<span className="truncate">{i.email}</span>
<span className={i.status === "approved" ? "text-accent" : "text-red-400"}>
{i.status}
</span>
</div>
))}
</div>
</details>
)}
</Section>
);
}
function InviteRow({
inv,
onApprove,
onDeny,
busy,
}: {
inv: Invite;
onApprove: () => void;
onDeny: () => void;
busy: boolean;
}) {
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
requested {new Date(inv.requested_at).toLocaleString()}
</div>
)}
</div>
<Tooltip hint="Approve — whitelist this email and email them they're in.">
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label="Approve"
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint="Deny this request.">
<button
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label="Deny"
>
<X className="w-4 h-4" />
</button>
</Tooltip>
</div>
);
}