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(),
"title": func.lower(Video.title).asc().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,6 +224,11 @@ def get_feed(
db: Session = Depends(get_db),
) -> dict:
query, _status = _filtered_query(db, user, **params)
if sort == "priority":
query = query.order_by(
Subscription.priority.desc(), Video.published_at.desc().nulls_last()
)
else:
order = SORTS.get(sort)
if order is None and sort == "shuffle":
order = func.md5(func.concat(Video.id, str(seed)))

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"
/>
</div>
<Tooltip
side="bottom"
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."
>
<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"
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>
<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 */}
<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) => (
<span
key={t.id}
@ -232,15 +247,17 @@ function ChannelRow({
c.hidden ? "opacity-60" : ""
}`}
>
<div className="flex flex-col items-center">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" title="Raise priority">
<Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
<div className="flex flex-col items-center cursor-help">
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
<ArrowUp className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" title="Lower priority">
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
</Tooltip>
{c.thumbnail_url ? (
<img src={c.thumbnail_url} alt="" className="w-10 h-10 rounded-full shrink-0" />
@ -286,9 +303,17 @@ function ChannelRow({
</div>
</div>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" title={c.hidden ? "Unhide" : "Hide from feed"}>
<Tooltip
hint={
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>
);
}

View file

@ -60,7 +60,7 @@ export default function SettingsPanel({
onClick={close}
/>
<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
? "animate-[panelOut_0.19s_ease-in_forwards]"
: "animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]"
@ -76,34 +76,47 @@ export default function SettingsPanel({
</button>
</div>
{/* 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">
<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-1.5 px-3 py-1.5 rounded-full text-sm transition ${
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
? "bg-accent text-accent-fg shadow-md shadow-accent/20 font-medium"
? "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" />
<t.icon className="w-4 h-4 shrink-0" />
{t.label}
</button>
);
})}
</div>
</nav>
<div className="flex-1 overflow-y-auto p-4">
{tab === "appearance" && (
{/* 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} />
)}
{tab === "notifications" && <Notifications />}
{tab === "sync" && <Sync me={me} />}
{tab === "account" && <Account me={me} />}
{t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} />}
</div>
))}
</div>
</div>
</div>
</div>
@ -294,7 +307,7 @@ function Notifications() {
level: "info",
title: "Test notification",
message: "This is what a notification looks like.",
requiresInteraction: true,
sound: true,
})
}
className="mt-2 text-sm text-accent hover:underline"

View file

@ -41,6 +41,7 @@ const SORTS = [
{ id: "duration_asc", label: "Shortest" },
{ id: "title", label: "Name (AZ)" },
{ id: "subscribers", label: "Channel subscribers" },
{ id: "priority", label: "Channel priority" },
{ 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";
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
* 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({
hint,
side = "top",
@ -15,24 +18,53 @@ export default function Tooltip({
children: React.ReactNode;
}) {
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}</>;
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
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="inline-flex"
>
{children}
{coords &&
createPortal(
<div
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`}
style={{
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}
</span>
</div>,
document.body
)}
</span>
);
}

View file

@ -32,19 +32,21 @@ body {
/* ===== 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);
/* Mostly opaque so overlay menus/panels stay readable over arbitrary content;
the blur + slight translucency keep the glassy feel. */
background: color-mix(in srgb, var(--surface) 90%, 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:
inset 0 1px 0 color-mix(in srgb, #fff 14%, transparent),
0 18px 40px -16px rgba(0, 0, 0, 0.55);
inset 0 1px 0 color-mix(in srgb, #fff 15%, transparent),
0 18px 44px -16px rgba(0, 0, 0, 0.6);
}
.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);
background: color-mix(in srgb, var(--card) 78%, transparent);
backdrop-filter: blur(12px) saturate(1.3);
-webkit-backdrop-filter: blur(12px) saturate(1.3);
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent),
0 8px 22px -14px rgba(0, 0, 0, 0.45);

View file

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