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:
parent
b46c8300d1
commit
6486c3d1a9
13 changed files with 388 additions and 102 deletions
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
} from "./lib/sidebarLayout";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications } from "./lib/notifications";
|
import { configureNotifications } from "./lib/notifications";
|
||||||
|
import { setHintsEnabled } from "./lib/hints";
|
||||||
import Login from "./components/Login";
|
import Login from "./components/Login";
|
||||||
import Header from "./components/Header";
|
import Header from "./components/Header";
|
||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
|
|
@ -100,6 +101,9 @@ export default function App() {
|
||||||
saveLayoutLocal(l);
|
saveLayoutLocal(l);
|
||||||
}
|
}
|
||||||
if (prefs.notifications) configureNotifications(prefs.notifications);
|
if (prefs.notifications) configureNotifications(prefs.notifications);
|
||||||
|
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||||
|
if (typeof prefs.performanceMode === "boolean")
|
||||||
|
document.documentElement.dataset.perf = prefs.performanceMode ? "1" : "";
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, [meQuery.data?.id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
onViewChannel,
|
onViewChannel,
|
||||||
|
|
@ -76,11 +77,23 @@ export default function Channels({
|
||||||
{/* Per-user sync status */}
|
{/* Per-user sync status */}
|
||||||
{s && (
|
{s && (
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
||||||
<Stat label="Channels" value={s.channels_total} />
|
<Stat label="Channels" value={s.channels_total} hint="Channels you're subscribed to." />
|
||||||
<Stat label="Recent synced" value={`${s.channels_recent_synced}/${s.channels_total}`} />
|
<Stat
|
||||||
<Stat label="Full history" value={`${s.channels_deep_done}/${s.channels_total}`} />
|
label="Recent synced"
|
||||||
<Stat label="My videos" value={s.my_videos.toLocaleString()} />
|
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||||
<Stat label="Quota left" value={s.quota_remaining_today.toLocaleString()} />
|
hint="Channels whose recent uploads are in the local DB and show in your feed."
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Full history"
|
||||||
|
value={`${s.channels_deep_done}/${s.channels_total}`}
|
||||||
|
hint="Channels whose entire back-catalog is fetched. The rest backfill gradually."
|
||||||
|
/>
|
||||||
|
<Stat label="My videos" value={s.my_videos.toLocaleString()} hint="Total videos available across your channels." />
|
||||||
|
<Stat
|
||||||
|
label="Quota left"
|
||||||
|
value={s.quota_remaining_today.toLocaleString()}
|
||||||
|
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -166,16 +179,27 @@ export default function Channels({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string | number }) {
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<span>
|
<Tooltip hint={hint ?? ""}>
|
||||||
|
<span className={hint ? "cursor-help" : ""}>
|
||||||
<span className="text-fg font-semibold">{value}</span> {label}
|
<span className="text-fg font-semibold">{value}</span> {label}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SyncBadge({ ok, label }: { ok: boolean; label: string }) {
|
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
|
||||||
return (
|
return (
|
||||||
|
<Tooltip hint={hint ?? ""}>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
|
className={`text-[10px] px-1.5 py-0.5 rounded-full border ${
|
||||||
ok ? "border-accent/40 text-accent" : "border-border text-muted"
|
ok ? "border-accent/40 text-accent" : "border-border text-muted"
|
||||||
|
|
@ -183,6 +207,7 @@ function SyncBadge({ ok, label }: { ok: boolean; label: string }) {
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,7 +228,7 @@ function ChannelRow({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-3 p-2.5 rounded-xl border border-border bg-card/30 ${
|
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
||||||
c.hidden ? "opacity-60" : ""
|
c.hidden ? "opacity-60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -232,8 +257,16 @@ function ChannelRow({
|
||||||
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
|
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
<SyncBadge ok={c.recent_synced} label="recent" />
|
<SyncBadge
|
||||||
<SyncBadge ok={c.backfill_done} label="full" />
|
ok={c.recent_synced}
|
||||||
|
label="recent"
|
||||||
|
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
|
||||||
|
/>
|
||||||
|
<SyncBadge
|
||||||
|
ok={c.backfill_done}
|
||||||
|
label="full"
|
||||||
|
hint={c.backfill_done ? "Full history fetched." : "Only recent videos so far; full history pending."}
|
||||||
|
/>
|
||||||
{userTags.map((t) => {
|
{userTags.map((t) => {
|
||||||
const on = c.tag_ids.includes(t.id);
|
const on = c.tag_ids.includes(t.id);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ function ThemeMenu({
|
||||||
setTheme: (t: ThemePrefs) => void;
|
setTheme: (t: ThemePrefs) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-0 mt-2 w-60 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
<div className="glass absolute right-0 mt-2 w-60 rounded-xl p-3 z-30 animate-[popIn_0.16s_ease]">
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">Color scheme</div>
|
||||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||||
{SCHEMES.map((s) => (
|
{SCHEMES.map((s) => (
|
||||||
|
|
@ -219,7 +219,7 @@ function AccountMenu({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-border bg-surface shadow-2xl p-3 z-30">
|
<div className="glass absolute right-0 mt-2 w-64 rounded-xl p-3 z-30 animate-[popIn_0.16s_ease]">
|
||||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||||
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
|
<Avatar me={me} className="w-10 h-10 text-sm shrink-0" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
<div className="min-h-screen grid place-items-center bg-bg text-fg">
|
||||||
<div className="w-[min(92vw,420px)] rounded-2xl border border-border bg-card/70 backdrop-blur p-10 text-center shadow-2xl">
|
<div className="glass w-[min(92vw,420px)] rounded-2xl p-10 text-center">
|
||||||
<div className="text-3xl font-bold tracking-tight">
|
<div className="text-3xl font-bold tracking-tight">
|
||||||
Sub<span className="text-accent">feed</span>
|
Sub<span className="text-accent">feed</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default function NotificationCenter({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-surface shadow-2xl z-30 flex flex-col max-h-[70vh]">
|
<div className="glass absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||||
<div className="text-sm font-semibold">Notifications</div>
|
<div className="text-sm font-semibold">Notifications</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
|
import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
notify,
|
notify,
|
||||||
type NotifSettings,
|
type NotifSettings,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
|
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||||
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
|
||||||
|
|
@ -34,39 +36,65 @@ export default function SettingsPanel({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<TabId>("appearance");
|
const [tab, setTab] = useState<TabId>("appearance");
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(onClose, 190);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") close();
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [close]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-40 flex justify-end">
|
<div className="fixed inset-0 z-40 flex justify-end">
|
||||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
<div
|
||||||
<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]">
|
className={`absolute inset-0 bg-black/40 ${
|
||||||
<div className="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
|
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>
|
<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" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 px-3 py-2 border-b border-border shrink-0 overflow-x-auto">
|
{/* Wrapping pill tabs — no horizontal scrollbar, prominent active state. */}
|
||||||
{TABS.map((t) => (
|
<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
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(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 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition ${
|
||||||
tab === t.id ? "bg-card text-fg" : "text-muted hover:text-fg"
|
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.icon className="w-4 h-4" />
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<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 (
|
return (
|
||||||
<label className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
|
||||||
<span>{label}</span>
|
<Tooltip hint={hint ?? ""}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
{children}
|
{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"}`}
|
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
checked ? "left-[18px]" : "left-0.5"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
@ -130,6 +174,8 @@ function Appearance({
|
||||||
setView: (v: "grid" | "list") => void;
|
setView: (v: "grid" | "list") => void;
|
||||||
}) {
|
}) {
|
||||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||||
|
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||||
}, [perf]);
|
}, [perf]);
|
||||||
|
|
@ -138,21 +184,25 @@ function Appearance({
|
||||||
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
||||||
api.savePrefs({ performanceMode: v }).catch(() => {});
|
api.savePrefs({ performanceMode: v }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function toggleHints(v: boolean) {
|
||||||
|
setHintsEnabled(v);
|
||||||
|
api.savePrefs({ hints: v }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title="Color scheme">
|
<Section title="Color scheme">
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{SCHEMES.map((s) => (
|
{SCHEMES.map((s) => (
|
||||||
|
<Tooltip key={s.id} hint={s.name}>
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
|
||||||
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
||||||
title={s.name}
|
className={`h-9 w-full rounded-lg border-2 transition ${
|
||||||
className={`h-9 rounded-lg border-2 transition ${
|
|
||||||
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
||||||
}`}
|
}`}
|
||||||
style={{ background: s.swatch }}
|
style={{ background: s.swatch }}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -164,12 +214,21 @@ function Appearance({
|
||||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</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")} />
|
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||||
</Row>
|
</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} />
|
<Switch checked={perf} onChange={togglePerf} />
|
||||||
</Row>
|
</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>
|
||||||
|
|
||||||
<Section title="Text size">
|
<Section title="Text size">
|
||||||
|
|
@ -196,19 +255,31 @@ function Notifications() {
|
||||||
configureNotifications(next);
|
configureNotifications(next);
|
||||||
api.savePrefs({ notifications: next }).catch(() => {});
|
api.savePrefs({ notifications: next }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
const autoOn = cfg.durationMs > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Notifications">
|
<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 })} />
|
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||||
</Row>
|
</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="py-1.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Auto-dismiss</span>
|
<span className="text-muted text-xs">Dismiss after</span>
|
||||||
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={3000}
|
min={2000}
|
||||||
max={15000}
|
max={15000}
|
||||||
step={1000}
|
step={1000}
|
||||||
value={cfg.durationMs}
|
value={cfg.durationMs}
|
||||||
|
|
@ -216,8 +287,16 @@ function Notifications() {
|
||||||
className="w-full accent-accent mt-1"
|
className="w-full accent-accent mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<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"
|
className="mt-2 text-sm text-accent hover:underline"
|
||||||
>
|
>
|
||||||
Send a test notification
|
Send a test notification
|
||||||
|
|
@ -249,11 +328,30 @@ function Sync({ me }: { me: Me }) {
|
||||||
<Section title="My sync status">
|
<Section title="My sync status">
|
||||||
{s ? (
|
{s ? (
|
||||||
<div className="space-y-1.5 text-sm">
|
<div className="space-y-1.5 text-sm">
|
||||||
<Row label="Channels">{s.channels_total}</Row>
|
<Row label="Channels" hint="How many channels you're subscribed to.">
|
||||||
<Row label="Recent synced">{`${s.channels_recent_synced}/${s.channels_total}`}</Row>
|
{s.channels_total}
|
||||||
<Row label="Full history">{`${s.channels_deep_done}/${s.channels_total}`}</Row>
|
</Row>
|
||||||
<Row label="My videos">{s.my_videos.toLocaleString()}</Row>
|
<Row
|
||||||
<Row label="Quota left today">{s.quota_remaining_today.toLocaleString()}</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted text-sm">Loading…</div>
|
<div className="text-muted text-sm">Loading…</div>
|
||||||
|
|
@ -261,25 +359,29 @@ function Sync({ me }: { me: Me }) {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Actions">
|
<Section title="Actions">
|
||||||
|
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
|
||||||
<button
|
<button
|
||||||
onClick={() => syncSubs.mutate()}
|
onClick={() => syncSubs.mutate()}
|
||||||
disabled={syncSubs.isPending}
|
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"
|
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" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
Sync subscriptions
|
Sync subscriptions
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{me.role === "admin" && s && (
|
{me.role === "admin" && s && (
|
||||||
<Section title="Admin">
|
<Section title="Admin">
|
||||||
|
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
|
||||||
<button
|
<button
|
||||||
onClick={() => pauseResume.mutate(s.paused)}
|
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"
|
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 ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||||
{s.paused ? "Resume background sync" : "Pause background sync"}
|
{s.paused ? "Resume background sync" : "Pause background sync"}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -401,7 +401,7 @@ export default function Sidebar({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filters.channelId && !editing && (
|
{filters.channelId && !editing && (
|
||||||
<div className="rounded-xl border border-border bg-card/30">
|
<div className="glass-card rounded-xl">
|
||||||
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
|
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
|
||||||
<div className="px-3 pb-3 pt-2">
|
<div className="px-3 pb-3 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|
@ -474,7 +474,7 @@ function WidgetCard({
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`rounded-xl border border-border bg-card/30 ${
|
className={`glass-card rounded-xl ${
|
||||||
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
|
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
|
||||||
} ${hidden && editing ? "opacity-50" : ""}`}
|
} ${hidden && editing ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export default function Toaster() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className="relative overflow-hidden bg-surface border border-border rounded-xl shadow-2xl px-3 py-3 flex items-start gap-3"
|
className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
38
frontend/src/components/Tooltip.tsx
Normal file
38
frontend/src/components/Tooltip.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { hintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
|
|
||||||
|
type Side = "top" | "bottom" | "left";
|
||||||
|
|
||||||
|
/** Wrap any element to show a short glass hint caption on hover — but only while the
|
||||||
|
* app-wide hints toggle (Settings → Appearance) is on. */
|
||||||
|
export default function Tooltip({
|
||||||
|
hint,
|
||||||
|
side = "top",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
hint: string;
|
||||||
|
side?: Side;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
|
if (!enabled || !hint) return <>{children}</>;
|
||||||
|
|
||||||
|
const place =
|
||||||
|
side === "bottom"
|
||||||
|
? "top-full mt-2 left-1/2 -translate-x-1/2"
|
||||||
|
: side === "left"
|
||||||
|
? "right-full mr-2 top-1/2 -translate-y-1/2"
|
||||||
|
: "bottom-full mb-2 left-1/2 -translate-x-1/2";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="relative inline-flex group/tip">
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className={`glass pointer-events-none absolute ${place} z-50 w-max max-w-[220px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal opacity-0 group-hover/tip:opacity-100 transition-opacity duration-150`}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -170,7 +170,7 @@ export default function VideoCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group rounded-2xl border border-border bg-card/50 p-2.5 shadow-sm transition-all duration-150 hover:-translate-y-1 hover:shadow-2xl hover:bg-card hover:border-accent/40",
|
"group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
|
||||||
watched && "opacity-55"
|
watched && "opacity-55"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,77 @@ html {
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
/* Ambient backdrop so translucent "glass" surfaces have soft color to refract. */
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 620px at 12% -8%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%),
|
||||||
|
radial-gradient(1000px 700px at 112% 8%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
|
||||||
|
.glass {
|
||||||
|
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||||
|
backdrop-filter: blur(18px) saturate(1.6);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(1.6);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
|
||||||
|
0 18px 40px -16px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
.glass-card {
|
||||||
|
background: color-mix(in srgb, var(--card) 58%, transparent);
|
||||||
|
backdrop-filter: blur(10px) saturate(1.3);
|
||||||
|
-webkit-backdrop-filter: blur(10px) saturate(1.3);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent),
|
||||||
|
0 8px 22px -14px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.glass-hover:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent),
|
||||||
|
0 14px 30px -14px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Performance mode (Settings → Appearance) drops the expensive blur + soft shadows. */
|
||||||
|
html[data-perf="1"] .glass,
|
||||||
|
html[data-perf="1"] .glass-card {
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 2px 8px -4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
html[data-perf="1"] body {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Motion ===== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes overlayIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes overlayOut {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes panelIn {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes panelOut {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes popIn {
|
||||||
|
from { opacity: 0; transform: translateY(-6px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
|
/* ===== Color schemes (accent + neutrals), each with dark + light ===== */
|
||||||
|
|
|
||||||
36
frontend/src/lib/hints.ts
Normal file
36
frontend/src/lib/hints.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
|
||||||
|
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
|
||||||
|
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
|
||||||
|
const KEY = "subfeed.hints";
|
||||||
|
|
||||||
|
let enabled = load();
|
||||||
|
let listeners: Array<() => void> = [];
|
||||||
|
|
||||||
|
function load(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) !== "0"; // default ON
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hintsEnabled(): boolean {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHintsEnabled(v: boolean): void {
|
||||||
|
enabled = v;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, v ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeHints(listener: () => void): () => void {
|
||||||
|
listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
listeners = listeners.filter((l) => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,7 @@ function beep(): void {
|
||||||
try {
|
try {
|
||||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
audioCtx = audioCtx || new Ctx();
|
audioCtx = audioCtx || new Ctx();
|
||||||
|
if (audioCtx.state === "suspended") void audioCtx.resume();
|
||||||
const osc = audioCtx.createOscillator();
|
const osc = audioCtx.createOscillator();
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
osc.connect(gain);
|
osc.connect(gain);
|
||||||
|
|
@ -151,11 +152,14 @@ export function notify(input: NotifyInput): number {
|
||||||
const id = counter++;
|
const id = counter++;
|
||||||
const requiresInteraction = input.requiresInteraction ?? false;
|
const requiresInteraction = input.requiresInteraction ?? false;
|
||||||
const level = input.level ?? "info";
|
const level = input.level ?? "info";
|
||||||
|
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
|
||||||
const duration = requiresInteraction
|
const duration = requiresInteraction
|
||||||
? undefined
|
? undefined
|
||||||
: level === "error" || level === "fatal"
|
: level === "error" || level === "fatal"
|
||||||
? ERROR_TTL
|
? ERROR_TTL
|
||||||
: config.durationMs;
|
: config.durationMs > 0
|
||||||
|
? config.durationMs
|
||||||
|
: undefined;
|
||||||
items = [
|
items = [
|
||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue