fix(ui): portal tooltips, opaque glass, settings rail+stable height, notif test, channel help

- Tooltip: render in a portal with fixed positioning + edge-flip so hints are never
  clipped by overflow/stacking ancestors (fixes mispositioned/hidden bubbles app-wide).
- Glass: raise opacity so overlay menus/panels stay readable over content.
- SettingsPanel: vertical tab rail (no wrapping/jumping), content grid-stacked so the
  panel sizes to the tallest tab (stable height) and floats to its content height.
- Notifications: the test toast is now a normal auto-dismissing toast (with countdown
  bar) that also plays the sound via a new force-sound flag.
- Channel manager: explain priority/tags/hide and what 'Sync subscriptions' does;
  add a 'Channel priority' feed sort so priority is actually meaningful.
This commit is contained in:
npeter83 2026-06-11 21:30:25 +02:00
parent 6486c3d1a9
commit 7a189fd163
7 changed files with 160 additions and 79 deletions

View file

@ -208,6 +208,8 @@ SORTS = {
"duration_asc": Video.duration_seconds.asc().nulls_last(), "duration_asc": Video.duration_seconds.asc().nulls_last(),
"title": func.lower(Video.title).asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(),
# Your per-channel priority (set in the channel manager), newest first within a tier.
"priority": Subscription.priority.desc(),
} }
@ -222,10 +224,15 @@ def get_feed(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status = _filtered_query(db, user, **params)
order = SORTS.get(sort) if sort == "priority":
if order is None and sort == "shuffle": query = query.order_by(
order = func.md5(func.concat(Video.id, str(seed))) Subscription.priority.desc(), Video.published_at.desc().nulls_last()
query = query.order_by(order if order is not None else SORTS["newest"]) )
else:
order = SORTS.get(sort)
if order is None and sort == "shuffle":
order = func.md5(func.concat(Video.id, str(seed)))
query = query.order_by(order if order is not None else SORTS["newest"])
rows = db.execute(query.offset(offset).limit(limit + 1)).all() rows = db.execute(query.offset(offset).limit(limit + 1)).all()
has_more = len(rows) > limit has_more = len(rows) > limit

View file

@ -108,19 +108,34 @@ export default function Channels({
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
/> />
</div> </div>
<button <Tooltip
onClick={() => syncSubs.mutate()} side="bottom"
disabled={syncSubs.isPending} hint="Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them."
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" : ""}`} /> <button
Sync subscriptions onClick={() => syncSubs.mutate()}
</button> 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>
</div> </div>
<p className="text-xs text-muted mb-4 leading-relaxed">
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
by Channel priority, attach your own <b className="text-fg/80">tags</b> to filter the feed,
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
</p>
{/* Your tags */} {/* Your tags */}
<div className="flex flex-wrap items-center gap-1.5 mb-4"> <div className="flex flex-wrap items-center gap-1.5 mb-4">
<span className="text-xs uppercase tracking-wide text-muted mr-1">Your tags</span> <Tooltip hint="Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)">
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
Your tags
</span>
</Tooltip>
{userTags.map((t) => ( {userTags.map((t) => (
<span <span
key={t.id} key={t.id}
@ -232,15 +247,17 @@ function ChannelRow({
c.hidden ? "opacity-60" : "" c.hidden ? "opacity-60" : ""
}`} }`}
> >
<div className="flex flex-col items-center"> <Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="Raise priority"> <div className="flex flex-col items-center cursor-help">
<ArrowUp className="w-3.5 h-3.5" /> <button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
</button> <ArrowUp className="w-3.5 h-3.5" />
<span className="text-xs text-muted tabular-nums">{c.priority}</span> </button>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority"> <span className="text-xs text-muted tabular-nums">{c.priority}</span>
<ArrowDown className="w-3.5 h-3.5" /> <button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
</button> <ArrowDown className="w-3.5 h-3.5" />
</div> </button>
</div>
</Tooltip>
{c.thumbnail_url ? ( {c.thumbnail_url ? (
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" /> <img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
@ -286,9 +303,17 @@ function ChannelRow({
</div> </div>
</div> </div>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}> <Tooltip
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} hint={
</button> c.hidden
? "Hidden — this channel's videos are kept out of your feed. Click to show them again."
: "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube."
}
>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? "Unhide" : "Hide from feed"}>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
</div> </div>
); );
} }

View file

@ -60,7 +60,7 @@ export default function SettingsPanel({
onClick={close} onClick={close}
/> />
<div <div
className={`glass relative w-[min(94vw,460px)] h-full rounded-l-2xl flex flex-col ${ 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 closing
? "animate-[panelOut_0.19s_ease-in_forwards]" ? "animate-[panelOut_0.19s_ease-in_forwards]"
: "animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]" : "animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
@ -76,34 +76,47 @@ export default function SettingsPanel({
</button> </button>
</div> </div>
{/* Wrapping pill tabs — no horizontal scrollbar, prominent active state. */} <div className="flex flex-1 min-h-0">
<div className="flex flex-wrap gap-1.5 px-3 py-3 border-b border-border/60 shrink-0"> {/* Vertical tab rail — never wraps; active is a clear accent pill. */}
{TABS.map((t) => { <nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
const active = tab === t.id; {TABS.map((t) => {
return ( const active = tab === t.id;
<button 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} key={t.id}
onClick={() => setTab(t.id)} className={`[grid-area:1/1] p-4 ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm transition ${ tab === t.id ? "" : "invisible pointer-events-none"
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.id === "appearance" && (
{t.label} <Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
</button> )}
); {t.id === "notifications" && <Notifications />}
})} {t.id === "sync" && <Sync me={me} />}
</div> {t.id === "account" && <Account me={me} />}
</div>
<div className="flex-1 overflow-y-auto p-4"> ))}
{tab === "appearance" && ( </div>
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{tab === "notifications" && <Notifications />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} />}
</div> </div>
</div> </div>
</div> </div>
@ -294,7 +307,7 @@ function Notifications() {
level: "info", level: "info",
title: "Test notification", title: "Test notification",
message: "This is what a notification looks like.", message: "This is what a notification looks like.",
requiresInteraction: true, sound: true,
}) })
} }
className="mt-2 text-sm text-accent hover:underline" className="mt-2 text-sm text-accent hover:underline"

View file

@ -41,6 +41,7 @@ const SORTS = [
{ id: "duration_asc", label: "Shortest" }, { id: "duration_asc", label: "Shortest" },
{ id: "title", label: "Name (AZ)" }, { id: "title", label: "Name (AZ)" },
{ id: "subscribers", label: "Channel subscribers" }, { id: "subscribers", label: "Channel subscribers" },
{ id: "priority", label: "Channel priority" },
{ id: "shuffle", label: "Surprise me" }, { id: "shuffle", label: "Surprise me" },
]; ];

View file

@ -1,10 +1,13 @@
import { useSyncExternalStore } from "react"; import { useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { hintsEnabled, subscribeHints } from "../lib/hints"; import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom" | "left"; type Side = "top" | "bottom";
type Coords = { left: number; top: number; placement: Side };
/** Wrap any element to show a short glass hint caption on hover but only while the /** Wrap any element to show a short glass hint caption on hover but only while the
* app-wide hints toggle (Settings Appearance) is on. */ * app-wide hints toggle (Settings Appearance) is on. Rendered in a portal with
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
export default function Tooltip({ export default function Tooltip({
hint, hint,
side = "top", side = "top",
@ -15,24 +18,53 @@ export default function Tooltip({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef<HTMLSpanElement>(null);
const [coords, setCoords] = useState<Coords | null>(null);
function show() {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
// Prefer the requested side; flip to bottom if there's no room above.
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
setCoords({
left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92),
top: placement === "top" ? r.top - 8 : r.bottom + 8,
placement,
});
}
function hide() {
setCoords(null);
}
if (!enabled || !hint) return <>{children}</>; 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 ( return (
<span className="relative inline-flex group/tip"> <span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="inline-flex"
>
{children} {children}
<span {coords &&
role="tooltip" createPortal(
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`} <div
> role="tooltip"
{hint} style={{
</span> position: "fixed",
left: coords.left,
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-[100] w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</div>,
document.body
)}
</span> </span>
); );
} }

View file

@ -32,19 +32,21 @@ body {
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */ /* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
.glass { .glass {
background: color-mix(in srgb, var(--surface) 72%, transparent); /* Mostly opaque so overlay menus/panels stay readable over arbitrary content;
backdrop-filter: blur(18px) saturate(1.6); the blur + slight translucency keep the glassy feel. */
-webkit-backdrop-filter: blur(18px) saturate(1.6); background: color-mix(in srgb, var(--surface) 90%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent); backdrop-filter: blur(24px) saturate(1.7);
-webkit-backdrop-filter: blur(24px) saturate(1.7);
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent), inset 0 1px 0 color-mix(in srgb, #fff 15%, transparent),
0 18px 40px -16px rgba(0, 0, 0, 0.55); 0 18px 44px -16px rgba(0, 0, 0, 0.6);
} }
.glass-card { .glass-card {
background: color-mix(in srgb, var(--card) 58%, transparent); background: color-mix(in srgb, var(--card) 78%, transparent);
backdrop-filter: blur(10px) saturate(1.3); backdrop-filter: blur(12px) saturate(1.3);
-webkit-backdrop-filter: blur(10px) saturate(1.3); -webkit-backdrop-filter: blur(12px) saturate(1.3);
border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
box-shadow: box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent), inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45); 0 8px 22px -14px rgba(0, 0, 0, 0.45);

View file

@ -41,6 +41,7 @@ export interface NotifyInput {
action?: NotifAction; action?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
requiresInteraction?: boolean; requiresInteraction?: boolean;
sound?: boolean; // force the alert tone (when sound is enabled) even for an info toast
} }
const HISTORY_KEY = "subfeed.notifications"; const HISTORY_KEY = "subfeed.notifications";
@ -177,7 +178,7 @@ export function notify(input: NotifyInput): number {
}, },
]; ];
emit(); emit();
if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) { if (config.sound && (input.sound || requiresInteraction || level === "error" || level === "fatal")) {
beep(); beep();
} }
if (duration) setTimeout(() => dismiss(id), duration); if (duration) setTimeout(() => dismiss(id), duration);