feat(ui): liquid-glass design system, settings polish, hints, notif fixes

- Add a theme-aware glass surface system (.glass/.glass-card + ambient backdrop,
  performance-mode opt-out) and apply it across panels, popovers, toasts, cards,
  sidebar widgets, channel rows, video cards and login.
- SettingsPanel: slide in/out animation, glass styling, wrapping pill tabs (no
  horizontal scrollbar) with a prominent active state.
- Notifications: auto-dismiss can be switched off (stays until closed); the test
  notification now also triggers the alert sound; resume a suspended AudioContext.
- Add an app-wide, toggleable hint/tooltip system (lib/hints + Tooltip) and wire
  hints across the settings and channel-manager surfaces; persisted per account.
This commit is contained in:
npeter83 2026-06-11 21:08:35 +02:00
parent a8822d3935
commit 002579e5e5
13 changed files with 388 additions and 102 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
@ -9,6 +9,8 @@ import {
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 }[] = [
@ -34,39 +36,65 @@ export default function SettingsPanel({
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") onClose();
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
}, [close]);
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative w-[min(92vw,440px)] h-full bg-surface border-l border-border shadow-2xl flex flex-col animate-[fadeIn_0.15s_ease]">
<div className="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
<div
className={`absolute inset-0 bg-black/40 ${
closing ? "animate-[overlayOut_0.19s_ease_forwards]" : "animate-[overlayIn_0.2s_ease]"
}`}
onClick={close}
/>
<div
className={`glass relative w-[min(94vw,460px)] h-full rounded-l-2xl 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={onClose} className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition">
<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 gap-1 px-3 py-2 border-b border-border shrink-0 overflow-x-auto">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition ${
tab === t.id ? "bg-card text-fg" : "text-muted hover:text-fg"
}`}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
))}
{/* Wrapping pill tabs — no horizontal scrollbar, prominent active state. */}
<div className="flex flex-wrap gap-1.5 px-3 py-3 border-b border-border/60 shrink-0">
{TABS.map((t) => {
const active = tab === t.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition ${
active
? "bg-accent text-accent-fg shadow-md shadow-accent/20 font-medium"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
<t.icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
<div className="flex-1 overflow-y-auto p-4">
@ -91,12 +119,28 @@ function Section({ title, children }: { title: string; children: React.ReactNode
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
function Row({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex items-center justify-between gap-3 py-1.5 text-sm">
<span>{label}</span>
<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}
</label>
</div>
);
}
@ -108,7 +152,7 @@ function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean
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 transition-all ${
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
@ -130,6 +174,8 @@ function Appearance({
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]);
@ -138,21 +184,25 @@ function Appearance({
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) => (
<button
key={s.id}
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
title={s.name}
className={`h-9 rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
<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>
@ -164,12 +214,21 @@ function Appearance({
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view">
<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">
<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">
@ -196,28 +255,48 @@ function Notifications() {
configureNotifications(next);
api.savePrefs({ notifications: next }).catch(() => {});
}
const autoOn = cfg.durationMs > 0;
return (
<Section title="Notifications">
<Row label="Sound on attention-needing alerts">
<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>
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span>Auto-dismiss</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
<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>
<input
type="range"
min={3000}
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", message: "This is a test notification." })}
onClick={() =>
notify({
level: "info",
title: "Test notification",
message: "This is what a notification looks like.",
requiresInteraction: true,
})
}
className="mt-2 text-sm text-accent hover:underline"
>
Send a test notification
@ -249,11 +328,30 @@ function Sync({ me }: { me: Me }) {
<Section title="My sync status">
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels">{s.channels_total}</Row>
<Row label="Recent synced">{`${s.channels_recent_synced}/${s.channels_total}`}</Row>
<Row label="Full history">{`${s.channels_deep_done}/${s.channels_total}`}</Row>
<Row label="My videos">{s.my_videos.toLocaleString()}</Row>
<Row label="Quota left today">{s.quota_remaining_today.toLocaleString()}</Row>
<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. The rest backfill gradually (opt in per channel)."
>
{`${s.channels_deep_done}/${s.channels_total}`}
</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>
@ -261,25 +359,29 @@ function Sync({ me }: { me: Me }) {
</Section>
<Section title="Actions">
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent disabled:opacity-50 transition"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
Sync subscriptions
</button>
<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>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<button
onClick={() => pauseResume.mutate(s.paused)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border text-sm hover:border-accent 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 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>
)}
</>