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:
parent
6486c3d1a9
commit
7a189fd163
7 changed files with 160 additions and 79 deletions
|
|
@ -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,6 +224,11 @@ 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)
|
||||||
|
if sort == "priority":
|
||||||
|
query = query.order_by(
|
||||||
|
Subscription.priority.desc(), Video.published_at.desc().nulls_last()
|
||||||
|
)
|
||||||
|
else:
|
||||||
order = SORTS.get(sort)
|
order = SORTS.get(sort)
|
||||||
if order is None and sort == "shuffle":
|
if order is None and sort == "shuffle":
|
||||||
order = func.md5(func.concat(Video.id, str(seed)))
|
order = func.md5(func.concat(Video.id, str(seed)))
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
<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
|
<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>
|
||||||
</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">
|
||||||
|
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
|
||||||
<ArrowUp className="w-3.5 h-3.5" />
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
<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" />
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
||||||
|
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" />}
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */}
|
||||||
|
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||||
{TABS.map((t) => {
|
{TABS.map((t) => {
|
||||||
const active = tab === t.id;
|
const active = tab === t.id;
|
||||||
return (
|
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-full text-sm transition ${
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||||
active
|
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"
|
: "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}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
||||||
{tab === "appearance" && (
|
(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} />
|
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||||
)}
|
)}
|
||||||
{tab === "notifications" && <Notifications />}
|
{t.id === "notifications" && <Notifications />}
|
||||||
{tab === "sync" && <Sync me={me} />}
|
{t.id === "sync" && <Sync me={me} />}
|
||||||
{tab === "account" && <Account me={me} />}
|
{t.id === "account" && <Account me={me} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const SORTS = [
|
||||||
{ id: "duration_asc", label: "Shortest" },
|
{ id: "duration_asc", label: "Shortest" },
|
||||||
{ id: "title", label: "Name (A–Z)" },
|
{ id: "title", label: "Name (A–Z)" },
|
||||||
{ 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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
|
||||||
{children}
|
|
||||||
<span
|
<span
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={show}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
onFocus={show}
|
||||||
|
onBlur={hide}
|
||||||
|
className="inline-flex"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{coords &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
role="tooltip"
|
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}
|
{hint}
|
||||||
</span>
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue