Merge r8-popover-platform → dev (R8 popover platform)

The whole R8 epic: one anchored-popover platform (AnchoredPopover, over @floating-ui/react)
replacing ~11 hand-rolled positioning/dismissal engines, a shared layerStack for topmost-only
Escape across popovers/modals/players, S4 migration of every remaining engine, the /code-review
max fixes, and S5 player follow-ups (download works in fullscreen, transport controls de-duped).
No migration (db_revision stays 0061).
This commit is contained in:
2026-07-31 03:44:05 +02:00
30 changed files with 1962 additions and 1314 deletions
+12
View File
@@ -230,6 +230,7 @@ def _channel_detail_dict(
blocked: bool,
stored: int,
deep_requested: bool,
tag_ids: list[int],
) -> dict:
return {
"id": channel.id,
@@ -257,6 +258,9 @@ def _channel_detail_dict(
"backfill_done": channel.backfill_done,
"from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None,
# This user's personal tag links for the channel (same shape as the manager list), so the
# channel page can offer the same tag editor next to Subscribe.
"tag_ids": tag_ids,
}
@@ -297,6 +301,13 @@ def channel_detail(
).first() is not None
blocked = _is_blocked(db, user, channel_id)
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
tag_ids = list(
db.scalars(
select(ChannelTag.tag_id).where(
ChannelTag.user_id == user.id, ChannelTag.channel_id == channel_id
)
).all()
)
return _channel_detail_dict(
channel,
subscribed=subscribed,
@@ -304,6 +315,7 @@ def channel_detail(
blocked=blocked,
stored=int(stored),
deep_requested=bool(sub.deep_requested) if sub else False,
tag_ids=tag_ids,
)
+54
View File
@@ -0,0 +1,54 @@
"""DB-backed regression (R8 Part 2): GET /api/channels/{id} returns exactly THIS user's personal
tag links for the channel — not another user's, and not the auto/system links (user_id NULL) —
so the channel detail page can reuse the manager's tag editor. Would ship broken if the tag_ids
query ever dropped its user_id filter.
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import Channel, ChannelTag, Tag, User
def test_channel_detail_returns_only_this_users_tag_ids(db, monkeypatch):
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
ch = Channel(
id="UC_tags_test",
title="Tag Test Channel",
published_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
)
db.add(ch)
me = User(email="me@example.com")
other = User(email="other@example.com")
db.add_all([me, other])
db.flush()
mine = Tag(user_id=me.id, name="Mine", category="other")
theirs = Tag(user_id=other.id, name="Theirs", category="other")
auto = Tag(user_id=None, name="English", category="language") # a system/auto tag
db.add_all([mine, theirs, auto])
db.flush()
# My personal link, another user's link, and a system/auto link on the same channel — only MINE
# must come back for `me`.
db.add(ChannelTag(channel_id=ch.id, tag_id=mine.id, user_id=me.id, source="user"))
db.add(ChannelTag(channel_id=ch.id, tag_id=theirs.id, user_id=other.id, source="user"))
db.add(ChannelTag(channel_id=ch.id, tag_id=auto.id, user_id=None, source="auto"))
db.commit()
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: me
try:
client = TestClient(app) # no context-manager: skip lifespan (scheduler/HLS sweep)
resp = client.get(f"/api/channels/{ch.id}")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
assert resp.json()["tag_ids"] == [mine.id]
+60
View File
@@ -9,6 +9,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1",
@@ -995,6 +996,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.20",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.9",
"@floating-ui/utils": "^0.2.12",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.8.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -5294,6 +5348,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+1
View File
@@ -22,6 +22,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1",
+39 -109
View File
@@ -1,28 +1,34 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
const MARGIN = 8;
const POP_W = 480; // the overlay's fixed width (px)
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
* (the same content as the detail page's About tab — description, links, country, language, topics,
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables.
* keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
* ["channel", id] react-query cache with the channel page, so the list stays light. The card is
* interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
* gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery
* tables.
*
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
* properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration.
*/
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim();
const ref = useRef<HTMLSpanElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const pop = useAnchoredPopover({
open: open && !!preview,
onOpenChange: setOpen,
mode: "hover",
offset: 6,
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
hoverDelay: { open: 0, close: 160 },
});
const { data: ch } = useQuery({
queryKey: qk.channel(channelId),
@@ -31,105 +37,29 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
staleTime: 5 * 60_000,
});
function place() {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
top: r.bottom + 6,
});
}
function show() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
place();
setOpen(true);
}
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
// vanishing — the overlay's own mouseenter cancels it.
function scheduleHide() {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setOpen(false), 160);
}
function cancelHide() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[],
);
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
// when the scroll happens inside the overlay itself (it is scrollable).
useEffect(() => {
if (!open) return;
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
// Flip above the anchor if the overlay would run off the bottom of the viewport.
useLayoutEffect(() => {
const pop = popRef.current;
const el = ref.current;
if (!open || !coords || !pop || !el) return;
const r = el.getBoundingClientRect();
const h = pop.offsetHeight;
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
const top = r.top - 6 - h;
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
}
}, [open, coords, ch]);
if (!preview) return <span className="text-muted">—</span>;
return (
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={scheduleHide}
onFocus={show}
onBlur={scheduleHide}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{preview}
{open &&
coords &&
createPortal(
<div
ref={popRef}
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
)}
</div>,
document.body,
<>
<span
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
tabIndex={0}
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
>
{preview}
</span>
<PopoverSurface
popover={pop}
className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
)}
</span>
</PopoverSurface>
</>
);
}
+85 -103
View File
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
className,
@@ -21,60 +22,25 @@ export default function AddToPlaylist({
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId),
enabled: open,
});
// Re-place once the panel is actually rendered (so we know its real height) and whenever
// its content — hence height — changes, so the flip-above decision uses the true size.
useLayoutEffect(() => {
if (open) place();
}, [open, membership.data]);
function place() {
const r = triggerRef.current?.getBoundingClientRect();
if (!r) return;
const width = 240;
const margin = 8;
// Use the panel's real height once it's mounted; estimate on the first open.
const h = panelRef.current?.offsetHeight ?? 300;
let top = r.bottom + 6;
if (top + h > window.innerHeight - margin) {
// Not enough room below — open upward, clamped to the viewport.
top = Math.max(margin, r.top - h - 6);
}
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
setCoords({ top: Math.round(top), left: Math.round(left) });
}
function toggleOpen(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!open) place();
setOpen((o) => !o);
}
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
useEffect(() => {
if (!open) return;
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [open]);
function refresh() {
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -95,8 +61,7 @@ export default function AddToPlaylist({
}
}
async function createAndAdd(e: React.FormEvent) {
e.preventDefault();
async function createAndAdd() {
const name = newName.trim();
if (!name || busy) return;
setBusy(true);
@@ -117,8 +82,15 @@ export default function AddToPlaylist({
return (
<>
<button
ref={triggerRef}
onClick={toggleOpen}
ref={pop.refs.setReference}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
@@ -126,59 +98,69 @@ export default function AddToPlaylist({
<ListPlus className="w-4 h-4" />
</button>
{open &&
createPortal(
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
ref={fade.ref}
style={fade.style}
aria-busy={busy}
className="max-h-56 overflow-y-auto no-scrollbar"
>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div
ref={fade.ref}
style={fade.style}
className="max-h-56 overflow-y-auto no-scrollbar"
>
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<button
key={pl.id}
onClick={() => toggle(pl)}
disabled={busy}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
)}
{lists.map((pl) => (
<PopoverItem
key={pl.id}
// A video can sit in several playlists at once — checkboxes, not one choice.
role="menuitemcheckbox"
checked={pl.has_video}
onSelect={() => toggle(pl)}
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</button>
))}
</div>
<form
onSubmit={createAndAdd}
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
>
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</form>
</div>,
document.body,
)}
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
)}
</PopoverItem>
))}
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss; every other key is text editing here and
// must not reach the surrounding menu's list navigation.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
void createAndAdd();
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
</div>
</div>
</PopoverSurface>
</>
);
}
+442
View File
@@ -0,0 +1,442 @@
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type ReactNode,
} from "react";
import {
useFloating,
autoUpdate,
offset as offsetMiddleware,
flip,
shift,
size,
hide,
useClick,
useHover,
useFocus,
useDismiss,
useRole,
useListNavigation,
useTypeahead,
useInteractions,
useFloatingNodeId,
useListItem,
safePolygon,
FloatingNode,
FloatingList,
FloatingPortal,
FloatingFocusManager,
type Placement,
type UseRoleProps,
} from "@floating-ui/react";
import { useLayer } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
// - flip() → measured two-way flip (top/bottom) when a side runs out of room
// - shift() → slides back into view horizontally (the old MEASURED-width clamp)
// - hide() → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we
// hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back)
// - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
// - useTypeahead → type-to-focus a matching item by its label
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
// Escape strands focus on <body>). Non-modal so the rest of the page stays interactive.
// Call sites render their options as <PopoverItem> — it self-registers (useListItem) and carries the
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
export interface AnchoredPopoverOptions {
open: boolean;
onOpenChange: (open: boolean, event?: Event) => void;
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
mode?: PopoverMode;
placement?: Placement;
/** Gap between trigger and panel, px. Default 4. */
offset?: number;
/** Hover mode only: open/close intent delays, ms. */
hoverDelay?: number | { open?: number; close?: number };
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
role?: UseRoleProps["role"];
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
fitHeight?: boolean;
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
* contain a free-text input, where character keys belong to the text field. */
typeahead?: boolean;
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
hostsInput?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
selectedIndex?: number | null;
}
// Internal: call sites use the inferred hook return, they don't name the type.
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const mode = opts.mode ?? "menu";
const isHover = mode === "hover";
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
const isNav = mode === "menu" || mode === "listbox";
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId();
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
nodeId,
open: opts.open,
onOpenChange: opts.onOpenChange,
placement: opts.placement ?? "bottom-start",
whileElementsMounted: autoUpdate,
middleware: [
offsetMiddleware(opts.offset ?? 4),
flip({ padding: 8 }),
shift({ padding: 8 }),
...(opts.fitHeight
? [
size({
padding: 8,
apply({ availableHeight, elements }) {
elements.floating.style.maxHeight = `${availableHeight}px`;
},
}),
]
: []),
hide({ padding: 8 }),
],
});
// A stale active item would highlight (and steal the next open's initial focus) the second time a
// menu opens; clear it whenever the surface closes.
useEffect(() => {
if (!opts.open) setActiveIndex(null);
}, [opts.open]);
const click = useClick(context, { enabled: !isHover });
const hover = useHover(context, {
enabled: isHover,
move: false,
delay: opts.hoverDelay ?? { open: 120, close: 140 },
handleClose: safePolygon(),
});
const focus = useFocus(context, { enabled: isHover });
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
// press stays with floating-ui (tree-aware).
const dismiss = useDismiss(context, { escapeKey: false });
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
const listNav = useListNavigation(context, {
enabled: isNav,
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
selectedIndex: opts.selectedIndex ?? null,
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
focusItemOnOpen: true,
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
// Enter to the hovered item. Turned off together with typeahead for such menus.
focusItemOnHover: !opts.hostsInput,
});
const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
listRef: labelsRef,
activeIndex,
onMatch: setActiveIndex,
});
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
// an Escape the user aimed at whatever is actually on screen.
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
return {
nodeId,
mode,
isNav,
open: opts.open,
selectedIndex: opts.selectedIndex ?? null,
refs,
floatingStyles,
context,
isPositioned,
referenceHidden,
elementsRef,
labelsRef,
activeIndex,
...interactions,
};
}
// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
interface PopoverListContextValue {
activeIndex: number | null;
getItemProps: AnchoredPopover["getItemProps"];
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
/** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving
* focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are
* one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once,
* `option`+`selected` for a listbox choice, plain `menuitem` for an action.
*
* The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`:
* a click-opened menu focuses the first item programmatically, which does NOT reliably trip the
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
* arbitrary value like the app's own glass idiom:
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
export function PopoverItem({
children,
onSelect,
role = "menuitem",
checked,
selected,
disabled,
className,
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
label,
...rest
}: {
children: ReactNode;
onSelect?: () => void;
role?: ItemRole;
checked?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
const active = ctx?.activeIndex === index;
const ariaProps =
role === "menuitemradio" || role === "menuitemcheckbox"
? { role, "aria-checked": !!checked }
: role === "option"
? { role, "aria-selected": !!selected }
: { role };
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
// getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the
// caller's first (e.g. a stopPropagation), then the select.
const mergedClick = (e: React.MouseEvent<HTMLButtonElement>) => {
rest.onClick?.(e);
if (!disabled) onSelect?.();
};
const handlers = ctx
? ctx.getItemProps({ ...rest, onClick: mergedClick })
: { ...rest, onClick: mergedClick };
return (
<button
ref={ref}
type="button"
{...ariaProps}
{...handlers}
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
// and disabled/className/data-active are the platform's to set.
tabIndex={active ? 0 : -1}
data-active={active ? "true" : undefined}
disabled={disabled}
className={className}
>
{children}
</button>
);
}
interface PopoverSurfaceProps {
popover: AnchoredPopover;
className?: string;
style?: CSSProperties;
children: ReactNode;
/** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
* take its name from. */
ariaLabel?: string;
focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
/** The surface never receives pointer events — for a passive caption that must not swallow a click
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
* content, links) must NOT set this. */
passthrough?: boolean;
}
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
* surface out (e.g. a hover card).
*
* A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10
* per table row) must not subscribe fullscreen listeners or build a portal container. */
export function PopoverSurface(props: PopoverSurfaceProps) {
if (!props.popover.open) return null;
return <PopoverSurfaceInner {...props} />;
}
function PopoverSurfaceInner({
popover,
className,
style,
children,
focusManaged,
initialFocus,
passthrough,
ariaLabel,
}: PopoverSurfaceProps) {
const {
context,
refs,
floatingStyles,
getFloatingProps,
getItemProps,
nodeId,
referenceHidden,
isPositioned,
mode,
isNav,
selectedIndex,
activeIndex,
elementsRef,
labelsRef,
} = popover;
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
// remount instead would rebuild the subtree and lose all of that.)
const fsRoot = useFullscreenElement();
const [container] = useState<HTMLDivElement | null>(() =>
typeof document === "undefined" ? null : document.createElement("div"),
);
useLayoutEffect(() => {
if (container) (fsRoot ?? document.body).appendChild(container);
}, [container, fsRoot]);
useEffect(() => () => container?.remove(), [container]);
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
style?: CSSProperties;
} & Record<string, unknown>;
// useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"];
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to
// the trigger on close).
const managed = focusManaged ?? mode !== "hover";
const surface = (
<div
ref={refs.setFloating}
{...floatingProps}
aria-label={ariaLabel}
className={zClass}
style={{
...floatingPropStyle,
...floatingStyles,
// Hide until the first position is measured (else it flashes at 0,0 before the transform
// lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
...(!isPositioned || referenceHidden
? { visibility: "hidden" as const, pointerEvents: "none" as const }
: null),
...(passthrough ? { pointerEvents: "none" as const } : null),
}}
>
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
positioned element is essential: the popIn keyframe animates `transform`, which would
otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
for the animation's duration — the "flash in the top-left corner" bug. */}
<div className={className} style={style}>
{children}
</div>
</div>
);
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
</PopoverListContext.Provider>
) : (
surface
);
return (
<FloatingNode id={nodeId}>
<FloatingPortal root={container ?? undefined}>
{managed ? (
<FloatingFocusManager
context={context}
modal={false}
// Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
// (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
// suppress the manager's initial focus with -1. For a plain nav menu we let the manager
// focus normally: it lands on the first tabbable child, which is the first roving item, or
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
// input, so such a menu still has a keyboard entry point instead of stranding focus.
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
returnFocus
>
{positioned}
</FloatingFocusManager>
) : (
positioned
)}
</FloatingPortal>
</FloatingNode>
);
}
+41 -1
View File
@@ -22,9 +22,11 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -166,6 +168,31 @@ export default function ChannelPage({
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
// invalidates the manager list so a tag added here shows there too.
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
const tagsQuery = useQuery({
queryKey: qk.tags(),
queryFn: api.tags,
enabled: !!ch?.subscribed && !me.is_demo,
});
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
old
? {
...old,
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
}
: old,
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
});
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
@@ -411,6 +438,19 @@ export default function ChannelPage({
{t("channel.subscribe")}
</button>
))}
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
otherwise), and personal tags are for your subscriptions — so only offer it on a
channel you actually follow. */}
{ch?.subscribed && !me.is_demo && (
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(channelId, name)}
// The header row has free width — show tags inline instead of collapsing to "+N".
bounded={false}
/>
)}
</div>
</div>
</div>
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
// (which grows the chip row) never moves it, and the editor popover doesn't drift.
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
// no longer gated on the top-of-page "Manage tags" dialog.
interface TagChipProps {
tag: Tag;
onClick?: () => void;
title?: string;
}
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
) : (
<span className={cls}>{tag.name}</span>
);
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */
function TagEditorMenu({
userTags,
attachedIds,
onToggle,
onCreate,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
const fade = useScrollFade();
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
if (!name) return;
onCreate(name);
setDraft("");
};
return (
<div className="flex flex-col">
<div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<PopoverItem
key={tg.id}
// Several tags can be on at once — a checkbox set, not one exclusive choice.
role="menuitemcheckbox"
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
})}
{userTags.length === 0 && (
<p className="text-[11px] text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
// list-navigation/typeahead handlers on the surrounding surface.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none"
/>
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
* so a table cell bounds it via the column width and a card bounds it via the card. */
export function ChannelTagsControl({
userTags,
attachedIds,
onToggle,
onCreate,
onChipClick,
bounded = true,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void;
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
* table (the manager). Set false where there is free width (the channel detail header), so tags
* fit inline instead of needlessly collapsing into "+N". */
bounded?: boolean;
}) {
const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
// rem boxes, never a px guess.
const rowRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(null);
const [visible, setVisible] = useState(attached.length);
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
useLayoutEffect(() => {
const row = rowRef.current;
const measure = measureRef.current;
if (!row || !measure) return;
const compute = () => {
const nodes = Array.from(measure.children) as HTMLElement[];
const pill = nodes[nodes.length - 1];
if (!pill) return;
const pillW = pill.offsetWidth;
const chips = nodes.slice(0, -1);
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
// between the last chip and the sample pill, rather than assuming a px constant.
const lastChip = chips[chips.length - 1];
const gap = lastChip
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
: 0;
const avail = row.clientWidth;
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
// pill is needed. The reservation loop below reserves the pill's width for every chip except
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
// chip narrower than the pill), leaving "+N" beside empty space.
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
if (lastRight <= avail) {
setVisible(chips.length);
return;
}
let count = chips.length;
for (let i = 0; i < chips.length; i++) {
const chip = chips[i];
if (!chip) break;
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
const moreAfter = i < chips.length - 1;
if (right > avail - (moreAfter ? pillW + gap : 0)) {
count = i;
break;
}
}
setVisible(count);
};
compute();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(compute);
ro.observe(row);
return () => ro.disconnect();
}, [tagsKey]);
const hidden = attached.length - visible;
const [editorOpen, setEditorOpen] = useState(false);
const editPop = useAnchoredPopover({
open: editorOpen,
onOpenChange: setEditorOpen,
mode: "menu",
placement: "bottom-start",
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
// of the roving list so neither steals the field's caret.
hostsInput: true,
});
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
// popover's state closed so it can't linger open against a gone reference.
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
const pillClass =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`}
>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg) => (
<TagChip key={tg.id} tag={tg} />
))}
<span className={pillClass}>+{attached.length}</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<button
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3" />
</button>
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{attached.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
</PopoverSurface>
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<TagEditorMenu
userTags={userTags}
attachedIds={attachedIds}
onToggle={onToggle}
onCreate={onCreate}
/>
</PopoverSurface>
</div>
);
}
+31 -206
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -12,17 +12,14 @@ import {
History,
LayoutGrid,
Pencil,
Plus,
RefreshCw,
Table,
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -30,7 +27,8 @@ import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import Overlay from "./Overlay";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
@@ -198,21 +196,25 @@ export default function Channels() {
}, []);
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId],
}
: ch,
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
const { toggleTag, createAndAttachTag } = useChannelTagActions({
optimisticToggle: (id, tagId, attach) =>
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
(old ?? []).map((ch) =>
ch.id === id
? {
...ch,
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
}
: ch,
),
),
);
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
@@ -453,17 +455,21 @@ export default function Channels() {
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<TagsCell
c={c}
<ChannelTagsControl
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onTagClick={onFilterByTag}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
@@ -989,7 +995,7 @@ function SyncCell({ c }: { c: ManagedChannel }) {
);
}
return (
<div className="flex flex-wrap items-center gap-1">
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
@@ -1024,187 +1030,6 @@ function SyncCell({ c }: { c: ManagedChannel }) {
);
}
function TagsCell({
c,
userTags,
onToggleTag,
onTagClick,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
onTagClick: (tagId: number, name: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
null,
);
const fade = useScrollFade();
const ref = useRef<HTMLDivElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
useDismiss(open, () => setOpen(false), [ref, popRef]);
// Keep the scroll-fade hook's callback ref working alongside our own.
const setPopRef = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
fade.ref(node);
},
[fade.ref],
);
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
// re-runs this once it is.
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
const w = popRef.current?.offsetWidth ?? 0;
const left =
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
}, []);
const place = useCallback(() => {
const r = btnRef.current?.getBoundingClientRect();
if (!r) return false;
setCoords(coordsFrom(r, false));
return true;
}, [coordsFrom]);
const toggle = () => {
if (open) setOpen(false);
else if (place()) setOpen(true);
};
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
// there is room above), and apply the width clamp now that the menu can actually be measured.
useLayoutEffect(() => {
const pop = popRef.current;
const r = btnRef.current?.getBoundingClientRect();
if (!open || !pop || !coords || !r) return;
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
const fitsAbove = r.top - 4 - h >= 8;
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
const above = coords.bottom !== undefined;
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
const next = coordsFrom(r, flip);
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
setCoords(next);
}
}, [open, coords, coordsFrom]);
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
// Scrolling INSIDE the menu must not move it.
useEffect(() => {
if (!open) return;
let raf = 0;
const reposition = () => {
raf = 0;
const r = btnRef.current?.getBoundingClientRect();
const scroller = document.querySelector("main")?.getBoundingClientRect();
const topEdge = scroller ? scroller.top : 0;
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
setOpen(false);
return;
}
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
};
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open, coordsFrom]);
if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
return (
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{attached.map((tg) => (
<button
key={tg.id}
onClick={() => onTagClick(tg.id, tg.name)}
title={t("channels.row.filterFeedByTag", { name: tg.name })}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
>
{tg.name}
</button>
))}
<button
ref={btnRef}
onClick={toggle}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Plus className="w-3 h-3" />
</button>
{open && coords && (
<Overlay>
<div
ref={setPopRef}
style={{
...fade.style,
position: "fixed",
left: coords.left,
top: coords.top,
bottom: coords.bottom,
}}
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
</Overlay>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
+63 -122
View File
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in).
//
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
// otherwise trap an absolutely-positioned popover).
// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
// the first item when a menu opens, so a hover-open would yank focus off whatever the user was doing.
export default function LanguageSwitcher({
value,
onChange,
@@ -22,128 +23,68 @@ export default function LanguageSwitcher({
variant?: "header" | "rail";
}) {
const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
function openNow() {
if (closeTimer.current) clearTimeout(closeTimer.current);
setOpen(true);
}
function closeSoon() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
function toggleRail() {
if (!open) {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setOpen((o) => !o);
}
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
// spans both the button and the floating panel).
useEffect(() => {
if (variant !== "rail" || !open) return;
function onDoc(e: MouseEvent) {
const target = e.target as Node;
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [variant, open]);
const menu = (
<div ref={panelRef} className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
const currentIdx = Math.max(
0,
LANGUAGES.findIndex((l) => l.code === value),
);
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
const rail = variant === "rail";
if (variant === "rail") {
return (
<>
<button
ref={btnRef}
onClick={toggleRail}
title={current.label}
aria-label={current.label}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-5 h-5" />
{/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */}
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
offset: rail ? 8 : 4,
// Single choice: opening lands on the language already in use.
selectedIndex: currentIdx,
});
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
title={current.label}
aria-label={current.label}
className={
rail
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
}
>
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
{rail ? (
/* Current-language badge (HU/EN) so the active language reads at a glance without
opening the menu. The ring matches the page background to detach it from the icon. */
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
{current.code.toUpperCase()}
</span>
</button>
{open &&
createPortal(
<div
className="z-rail"
style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}
>
{menu}
</div>,
document.body,
)}
</>
);
}
return (
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
<button
onClick={() => setOpen((o) => !o)}
title={current.label}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
>
<Globe className="w-4 h-4" />
<span className="text-xs font-semibold uppercase">{current.code}</span>
) : (
<span className="text-xs font-semibold uppercase">{current.code}</span>
)}
</button>
{open && (
<div
className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
key={l.code}
onClick={() => {
onChange(l.code);
setOpen(false);
}}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</button>
))}
</div>
)}
</div>
<PopoverSurface
popover={pop}
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{LANGUAGES.map((l) => (
<PopoverItem
key={l.code}
role="menuitemradio"
checked={l.code === value}
onSelect={() => {
onChange(l.code);
setOpen(false);
}}
label={l.label}
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
</PopoverItem>
))}
</PopoverSurface>
</>
);
}
+13 -18
View File
@@ -3,20 +3,15 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
let modalStack: number[] = [];
let nextModalId = 1;
/** How many <Modal>s are currently open. The players (which use their own window-level Escape
* listeners, not this stack) check it so their Escape defers to a Modal opened above them —
* otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */
export function modalCount(): number {
return modalStack.length;
}
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
// download dialog) would otherwise open invisibly.
export default function Modal({
title,
onClose,
@@ -31,6 +26,7 @@ export default function Modal({
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -41,10 +37,9 @@ export default function Modal({
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const id = nextModalId++;
modalStack.push(id);
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
if (e.key === "Escape" && isTopmost(id)) {
e.stopPropagation();
onCloseRef.current();
}
@@ -54,7 +49,7 @@ export default function Modal({
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
modalStack = modalStack.filter((x) => x !== id);
removeLayer(id);
document.body.style.overflow = prev;
};
}, []);
@@ -88,6 +83,6 @@ export default function Modal({
<div className="px-5 pb-5">{children}</div>
</div>
</div>,
document.body,
fsRoot ?? document.body,
);
}
+87 -112
View File
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -25,6 +24,7 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The
// modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes
@@ -57,41 +57,20 @@ export default function NavSidebar({
const expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
const acctPanelRef = useRef<HTMLDivElement | null>(null);
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
// clicks fall through to the controls behind it.
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
function toggleAccount() {
if (!acctOpen) {
const r = acctBtnRef.current?.getBoundingClientRect();
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
}
setAcctOpen((o) => !o);
}
// Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop
// div, which (sitting between the popover and the page) would break the popover's
// backdrop-filter and make it look solid.
useEffect(() => {
if (!acctOpen) return;
function onDoc(e: MouseEvent) {
const t = e.target as Node;
if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return;
setAcctOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setAcctOpen(false);
}
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, [acctOpen]);
// The account panel holds mixed content with its own tab order (profile, account switches, two
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
// comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
open: acctOpen,
onOpenChange: setAcctOpen,
mode: "dialog",
role: "dialog",
placement: "right-end",
offset: 8,
});
async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
@@ -338,10 +317,10 @@ export default function NavSidebar({
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button>
<div className="relative">
<div>
<button
ref={acctBtnRef}
onClick={toggleAccount}
ref={acctPop.refs.setReference}
{...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
>
@@ -359,79 +338,75 @@ export default function NavSidebar({
)}
</button>
{acctOpen &&
createPortal(
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</div>,
document.body,
<PopoverSurface
popover={acctPop}
ariaLabel={name}
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
src={me.avatar_url}
fallback={name}
className="w-10 h-10 rounded-full text-sm shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{name}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
</div>
</div>
{me.role === "admin" && (
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
<Shield className="w-3.5 h-3.5" />
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<button
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-[10px] shrink-0"
/>
<span className="min-w-0 text-left">
<span className="block text-[13px] text-fg truncate">
{a.display_name ?? a.email.split("@")[0]}
</span>
<span className="block text-[11px] text-muted truncate">{a.email}</span>
</span>
</button>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
<button
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
clearActiveAccount();
window.location.href = "/auth/login";
}}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
</button>
<button
onClick={logout}
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<LogOut className="w-4 h-4" />
{t("header.account.signOut")}
</button>
</div>
</PopoverSurface>
</div>
</div>
</nav>
+189 -196
View File
@@ -27,7 +27,8 @@ import {
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
@@ -67,6 +68,108 @@ type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
function PlayerTransportControls({
variant,
index,
queueLength,
autoMode,
loopMode,
onPrev,
onNext,
onCycleAuto,
onCycleLoop,
}: {
variant: "docked" | "modal";
index: number;
queueLength: number;
autoMode: AutoMode;
loopMode: LoopMode;
onPrev: () => void;
onNext: () => void;
onCycleAuto: () => void;
onCycleLoop: () => void;
}) {
const { t } = useTranslation();
const docked = variant === "docked";
const navBtn = docked
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
const navLabel = docked ? "hidden sm:inline" : "";
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
const toggle = (on: boolean) =>
docked
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
}`;
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className={navBtn}
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
className={navBtn}
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={onCycleAuto}
title={t("player.autoAdvance.hint")}
className={toggle(autoMode !== "off")}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={onCycleLoop}
title={t("player.loop.hint")}
className={toggle(loopMode !== "off")}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className={toggleLabel}>
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
</>
);
}
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
@@ -446,41 +549,21 @@ export default function PlayerModal({
}
};
// Lazy description (fetched only when the title is hovered). The popover is
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
// can't clip it. A small close grace lets the mouse travel title → popover.
// Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
// upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// rescues it when there is no room up there either, which the old fixed `bottom` could not. The
// hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card)
// and the travel grace are now the platform's, along with the safePolygon bridge that replaces the
// pair of hand-managed timers.
const [showDesc, setShowDesc] = useState(false);
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null,
);
const titleRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const openTimer = useRef<number | undefined>(undefined);
const openDescNow = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
const el = titleRef.current;
if (el) {
const r = el.getBoundingClientRect();
const width = Math.min(560, window.innerWidth - 32);
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
}
setShowDesc(true);
};
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
// quick pass over it doesn't flash the popover.
const scheduleOpenDesc = () => {
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
openTimer.current = window.setTimeout(openDescNow, 400);
};
const scheduleCloseDesc = () => {
window.clearTimeout(openTimer.current);
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
};
const descPop = useAnchoredPopover({
open: showDesc,
onOpenChange: setShowDesc,
mode: "hover",
placement: "top-start",
offset: 8,
hoverDelay: { open: 400, close: 150 },
});
const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId),
@@ -490,12 +573,28 @@ export default function PlayerModal({
staleTime: 5 * 60_000,
});
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it.
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
@@ -504,9 +603,6 @@ export default function PlayerModal({
setMaximized(false);
return;
}
// Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes
// it, ours would otherwise also fire and close the player underneath it (U-C).
if (modalCount() > 0) return;
onClose();
return;
}
@@ -560,8 +656,6 @@ export default function PlayerModal({
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
window.clearTimeout(volTimerRef.current);
// Flush a debounced volume write — closing the player right after a wheel spin (the normal
// way to leave) must not drop the level the user just set.
@@ -875,8 +969,9 @@ export default function PlayerModal({
bar — that stays reachable by hovering the video's bottom edge, just above ours. Always
visible (no auto-hide) so there's no guessing whether it will appear and no fight with the
seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback;
the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true
fullscreen — their popovers portal to <body>, outside the fullscreen element. */}
the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its
popover portals INTO the fullscreen element); download stays hidden there because it opens
a Modal, which still portals to <body> — outside the fullscreen element. */}
{(maximized || isFullscreen) && (
<div
onClick={(e) => e.stopPropagation()}
@@ -884,76 +979,30 @@ export default function PlayerModal({
>
{hasQueue && (
<>
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<SkipBack className="h-4 w-4" />
<span className="hidden sm:inline">{t("player.previous")}</span>
</button>
<span className="tabular-nums text-white/70">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
>
<span className="hidden sm:inline">{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
<span className="h-4 w-px bg-white/20" />
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
autoMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</span>
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
loopMode !== "off"
? "bg-white/20 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
<span className="hidden md:inline">
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</span>
</button>
<PlayerTransportControls
variant="docked"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
<span className="h-4 w-px bg-white/20" />
</>
)}
{!navigated && !isFullscreen && (
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
portal into the fullscreen element rather than <body> (a body-portaled node is
painted underneath the element holding the lock, i.e. invisible). */}
{!navigated && (
<AddToPlaylist
videoId={active.id}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
/>
)}
{!navigated && !isFullscreen && (
{!navigated && (
<DownloadButton
videoId={active.id}
title={active.title}
@@ -1051,61 +1100,17 @@ export default function PlayerModal({
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button
onClick={goPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
<SkipBack className="w-4 h-4" /> {t("player.previous")}
</button>
<span className="text-muted tabular-nums">
{index + 1} / {queue!.length}
</span>
<button
onClick={goNext}
disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
>
{t("player.next")} <SkipForward className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-border" />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
onClick={cycleAuto}
title={t("player.autoAdvance.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
autoMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{autoMode === "prev" ? (
<SkipBack className="h-3.5 w-3.5" />
) : autoMode === "random" ? (
<Shuffle className="h-3.5 w-3.5" />
) : (
<SkipForward className="h-3.5 w-3.5" />
)}
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
</button>
<button
onClick={cycleLoop}
title={t("player.loop.hint")}
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
loopMode !== "off"
? "bg-accent/15 text-accent"
: "text-muted hover:bg-surface hover:text-fg"
}`}
>
{loopMode === "one" ? (
<Repeat1 className="h-3.5 w-3.5" />
) : (
<Repeat className="h-3.5 w-3.5" />
)}
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
</button>
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}
@@ -1115,10 +1120,9 @@ export default function PlayerModal({
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */}
<span
ref={titleRef}
ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
>
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span>
@@ -1133,43 +1137,32 @@ export default function PlayerModal({
{t("player.back")}
</button>
)}
{showDesc &&
descRect &&
createPortal(
<PopoverSurface
popover={descPop}
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body,
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</PopoverSurface>
<button
data-testid="player-close"
onClick={onClose}
+17 -3
View File
@@ -46,7 +46,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { modalCount } from "./Modal";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -808,6 +808,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack();
}, [handleBack]);
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -874,8 +886,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else handleBack();
break;
case "Escape":
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C).
if (modalCount() > 0) break;
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);
+54 -59
View File
@@ -1,12 +1,11 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format";
import { useDismiss } from "../lib/useDismiss";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -21,12 +20,17 @@ export default function SyncStatus({
const { t } = useTranslation();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement | null>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter
// would otherwise trap its stacking and positioning.
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// stacking and positioning.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const { data } = useQuery({
queryKey: qk.myStatus(),
queryFn: api.myStatus,
@@ -106,16 +110,11 @@ export default function SyncStatus({
return (
<div className="pointer-events-auto shrink-0">
<button
ref={chipRef}
onClick={() => {
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here.
title={countsText}
aria-expanded={open}
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
>
{active ? (
@@ -129,50 +128,46 @@ export default function SyncStatus({
)}
</button>
{open &&
createPortal(
<div
ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</div>,
document.body,
<PopoverSurface
popover={pop}
ariaLabel={countsText}
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
>
<Database className="w-3.5 h-3.5 shrink-0" />
<span>
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
{t("header.sync.yours")}
<span className="opacity-40"> / </span>
{formatViews(data.total_videos)} {t("header.sync.total")}
</span>
</div>
{showMain && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
{pauseBtn}
</div>
)}
{notFull > 0 && (
<div className="flex items-center justify-between gap-1.5 pt-2">
<button
onClick={() => {
setOpen(false);
onGoToFullHistory();
}}
title={t("header.sync.fullHistoryTooltip")}
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
>
<History className="w-3.5 h-3.5" />
{t("header.sync.withoutFullHistory", { count: notFull })}
</button>
{!showMain && pauseBtn}
</div>
)}
</PopoverSurface>
</div>
);
}
+38 -43
View File
@@ -1,5 +1,4 @@
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -9,10 +8,12 @@ import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it.
// Channel manager). The popover rides the R8 platform, so the list's own scroll can't clip it and
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
function TagRow({
tag,
channels,
@@ -29,25 +30,20 @@ function TagRow({
const { t } = useTranslation();
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
const v = name.trim();
if (v && v !== tag.name) onRename(v);
else setName(tag.name);
};
const openPop = () => {
window.clearTimeout(closeTimer.current);
if (channels.length === 0) return;
const r = countRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.right + 8, top: r.top });
setOpen(true);
};
const closeSoon = () => {
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
const pop = useAnchoredPopover({
open: open && channels.length > 0,
onOpenChange: setOpen,
mode: "hover",
placement: "right-start",
offset: 8,
hoverDelay: { open: 0, close: 150 },
});
return (
<div className="flex items-center gap-2">
<input
@@ -58,9 +54,8 @@ function TagRow({
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<span
ref={countRef}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
@@ -77,30 +72,30 @@ function TagRow({
>
<Trash2 className="w-4 h-4" />
</button>
{open &&
createPortal(
<div
ref={popFade.ref}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>,
document.body,
)}
<PopoverSurface
popover={pop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
</div>
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
<div
ref={popFade.ref}
style={popFade.style}
className="max-h-72 overflow-y-auto no-scrollbar"
>
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => onPickChannel(ch.title)}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
>
{ch.title}
</button>
))}
</div>
</PopoverSurface>
</div>
);
}
+30 -73
View File
@@ -1,16 +1,13 @@
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useState, useSyncExternalStore } from "react";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom";
type Coords = { left: number; top: number; placement: Side };
const MARGIN = 8;
const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get
/** Wrap any element to show a short glass hint caption on hover — but only while the
* 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. */
* app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge. */
export default function Tooltip({
hint,
side = "top",
@@ -21,74 +18,34 @@ export default function Tooltip({
children: React.ReactNode;
}) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLDivElement>(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";
const center = r.left + r.width / 2;
// Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
// first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
// the centered box off-screen. The layout effect below refines this to the actual width.
setCoords({
left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
top: placement === "top" ? r.top - 8 : r.bottom + 8,
placement,
});
}
function hide() {
setCoords(null);
}
// Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
// need the full 240px reservation), still clamped inside the viewport.
useLayoutEffect(() => {
const tip = tipRef.current;
const el = ref.current;
if (!coords || !tip || !el) return;
const r = el.getBoundingClientRect();
const half = tip.offsetWidth / 2;
const left = Math.min(
Math.max(r.left + r.width / 2, half + MARGIN),
window.innerWidth - half - MARGIN,
);
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
}, [coords]);
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open: open && enabled && !!hint,
onOpenChange: setOpen,
mode: "hover",
placement: side,
offset: 8,
// Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent
// default suits interactive cards but makes a fast sweep across an icon row surface no hints.
hoverDelay: { open: 0, close: 0 },
});
if (!enabled || !hint) return <>{children}</>;
return (
<span
ref={ref}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="inline-flex"
>
{children}
{coords &&
createPortal(
<div
ref={tipRef}
role="tooltip"
style={{
position: "fixed",
left: coords.left,
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-tooltip 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 ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
{children}
</span>
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
overlaps while the hover-close delay runs out. */}
<PopoverSurface
popover={pop}
passthrough
className="glass 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}
</PopoverSurface>
</>
);
}
+51 -100
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { useDismiss } from "../lib/useDismiss";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
export interface ViewOption<T extends string> {
id: T;
@@ -13,9 +13,11 @@ export interface ViewOption<T extends string> {
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
// density modes; the managers' layouts) without re-deriving the menu behaviour.
//
// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
// This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine.
// It now rides the R8 popover platform like every other menu, which supplies the same model
// (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the
// two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead
// of being absolutely positioned inside whatever ancestor happens to clip it.
export default function ViewSwitcher<T extends string>({
value,
options,
@@ -34,19 +36,6 @@ export default function ViewSwitcher<T extends string>({
labelClassName?: string;
}) {
const [open, setOpen] = useState(false);
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
const [focusIdx, setFocusIdx] = useState(0);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
function close({ refocus = true }: { refocus?: boolean } = {}) {
setOpen(false);
if (refocus) btnRef.current?.focus();
}
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
@@ -54,55 +43,24 @@ export default function ViewSwitcher<T extends string>({
0,
options.findIndex((o) => o.id === value),
);
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
// would only schedule the update, so the focus effect below would still see the previous index
// and focus the wrong item for one render before correcting.
function openMenu() {
setFocusIdx(currentIdx);
setOpen(true);
}
// Clamped at render, so a caller whose option list shrinks while the menu is open can't leave
// the roving tabindex pointing past the end (which would make every item un-tabbable).
const rovingIdx = Math.min(focusIdx, options.length - 1);
useEffect(() => {
if (open) itemRefs.current[rovingIdx]?.focus();
}, [open, rovingIdx]);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
placement: "bottom-end",
// Opening a "pick one of these modes" menu should land on the mode that is already picked.
selectedIndex: currentIdx,
});
const current = options[currentIdx];
if (!current) return null;
const CurrentIcon = current.icon;
function onMenuKeyDown(e: React.KeyboardEvent) {
const last = options.length - 1;
if (e.key === "Escape") {
// Stop it reaching useDismiss's document listener, which would close without refocusing.
e.stopPropagation();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIdx((i) => (i >= last ? 0 : i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIdx((i) => (i <= 0 ? last : i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setFocusIdx(0);
} else if (e.key === "End") {
e.preventDefault();
setFocusIdx(last);
} else if (e.key === "Tab") {
// Tabbing out of a menu closes it, but the focus is moving on by itself.
close({ refocus: false });
}
}
return (
<div className="relative">
<>
<button
ref={btnRef}
onClick={() => (open ? close() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
@@ -115,46 +73,39 @@ export default function ViewSwitcher<T extends string>({
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-chrome min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o, i) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={on}
tabIndex={i === rovingIdx ? 0 : -1}
onClick={() => {
onChange(o.id);
close();
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
);
})}
</div>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to — otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o) => {
const Icon = o.icon;
const on = o.id === value;
return (
<PopoverItem
key={o.id}
role="menuitemradio"
checked={on}
onSelect={() => {
onChange(o.id);
setOpen(false);
}}
label={o.label}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</PopoverItem>
);
})}
</div>
)}
</div>
</PopoverSurface>
</>
);
}
+130 -122
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
import { type SortState } from "../lib/columnSort";
@@ -85,100 +85,145 @@ function FilterOptionList({ children }: { children: ReactNode }) {
);
}
// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW
// component type on every render: React unmounted and remounted the popover on each keystroke, so
// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
// MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a
// NEW component type on every render: React unmounted and remounted the popover on each keystroke,
// so the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The
// `autoFocus` masked it well enough that it read as "the filter is just twitchy".
function FilterPopover<T>({
//
// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel
// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the
// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go
// back), the restore-focus effect that stopped Escape stranding focus on <body>, and the useDismiss
// registration that had to include the trigger so its own mousedown didn't close-then-reopen.
function FilterFunnel<T>({
col,
value,
onApply,
panelRef,
open,
onOpenChange,
}: {
col: Column<T>;
value: string | string[] | undefined;
onApply: (key: string, value: string | string[]) => void;
panelRef: RefObject<HTMLDivElement>;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const f = col.filter!;
const inputRef = useRef<HTMLInputElement | null>(null);
// A text filter is a free-text field, so it is a dialog whose input takes focus; the option lists
// are real menus with roving focus. `select` is one exclusive choice (radio), `multi` is several
// independent ones (checkbox).
const isText = f.kind === "text";
const pop = useAnchoredPopover({
open,
onOpenChange,
mode: isText ? "dialog" : "menu",
role: isText ? "dialog" : "menu",
});
const filterOn = isActive(value);
return (
<div
ref={panelRef}
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
autoFocus
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<button
onClick={() => onApply(col.key, "")}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{t("datatable.all")}
</button>
{f.options.map((o) => (
<button
key={o.value}
onClick={() => onApply(col.key, o.value)}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("datatable.filter")}
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
>
<ListFilter className="w-3.5 h-3.5" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={col.header}
initialFocus={isText ? inputRef : undefined}
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
ref={inputRef}
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<PopoverItem
role="menuitemradio"
checked={!filterOn}
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{o.label}
</button>
))}
</div>
)}
{f.kind === "multi" && (
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
{f.options.map((o) => {
const arr = (value as string[]) ?? [];
const on = arr.includes(o.value);
return (
<button
{t("datatable.all")}
</PopoverItem>
{f.options.map((o) => (
<PopoverItem
key={o.value}
onClick={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
role="menuitemradio"
checked={value === o.value}
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
{o.label}
</PopoverItem>
))}
</div>
)}
{f.kind === "multi" && (
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
{f.options.map((o) => {
const arr = (value as string[]) ?? [];
const on = arr.includes(o.value);
return (
<PopoverItem
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
{on && <X className="w-2.5 h-2.5" />}
</span>
{o.label}
</button>
);
})}
</FilterOptionList>
)}
{isActive(value) && (
<button
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</div>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <X className="w-2.5 h-2.5" />}
</span>
{o.label}
</PopoverItem>
);
})}
</FilterOptionList>
)}
{filterOn && (
<button
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</PopoverSurface>
</>
);
}
@@ -203,33 +248,14 @@ export function ColumnHead<T>({
filters: FilterMap;
onFilter: (key: string, value: string | string[]) => void;
}) {
const { t } = useTranslation();
// Only one column's filter panel is open at a time; each funnel owns its own anchoring.
const [openFilter, setOpenFilter] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
// The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own
// mousedown closes the panel and the click that follows immediately reopens it — clicking the
// funnel a second time then looks like it does nothing. Also the thing to hand focus back to.
const triggerRef = useRef<HTMLButtonElement | null>(null);
const restoreFocus = useRef(false);
useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]);
// Escape (or an outside click) used to unmount the focused input and strand focus on <body>,
// so the next Tab restarted from the top of the page.
useEffect(() => {
if (openFilter) {
restoreFocus.current = true;
} else if (restoreFocus.current) {
restoreFocus.current = false;
triggerRef.current?.focus();
}
}, [openFilter]);
return (
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => {
const isSorted = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
return (
<th
key={col.key}
@@ -267,33 +293,15 @@ export function ColumnHead<T>({
<span>{col.header}</span>
)}
{col.filter && (
<button
// Captured at open time rather than bound with `ref`, so it survives the close
// (a `ref` scoped to the open column nulls out before focus can go back).
onClick={(e) => {
triggerRef.current = e.currentTarget;
setOpenFilter((o) => (o === col.key ? null : col.key));
}}
aria-label={t("datatable.filter")}
aria-expanded={openFilter === col.key}
aria-haspopup="dialog"
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
className={`-m-1.5 p-1.5 transition ${
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
}`}
>
<ListFilter className="w-3.5 h-3.5" />
</button>
<FilterFunnel
col={col}
value={filters[col.key]}
onApply={onFilter}
open={openFilter === col.key}
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
/>
)}
</span>
{openFilter === col.key && col.filter && (
<FilterPopover
col={col}
value={filters[col.key]}
onApply={onFilter}
panelRef={panelRef}
/>
)}
</th>
);
})}
+7 -3
View File
@@ -46,7 +46,11 @@
"tags": {
"yourTags": "Your tags",
"manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag."
},
"loading": "Loading channels…",
"empty": "No channels.",
@@ -103,8 +107,8 @@
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"fullHistoryQueued": "full history queued",
"fullHistoryComing": "full history coming",
"fullHistoryQueued": "queued",
"fullHistoryComing": "coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
+7 -3
View File
@@ -46,7 +46,11 @@
"tags": {
"yourTags": "Címkéid",
"manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült."
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
@@ -103,8 +107,8 @@
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
"resetBackfill": "Csatorna resetelése és újraletöltése",
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
"fullHistoryQueued": "teljes előzmény sorban",
"fullHistoryComing": "teljes előzmény úton",
"fullHistoryQueued": "sorban",
"fullHistoryComing": "úton",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
+1
View File
@@ -31,6 +31,7 @@ export interface ChannelDetail {
backfill_done: boolean;
from_explore: boolean;
details_synced: boolean;
tag_ids: number[];
}
export interface ExploreResult {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface Tag {
export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"),
createTag: (t: { name: string; color?: string; category?: string }) =>
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+15
View File
@@ -1,3 +1,5 @@
import { useSyncExternalStore } from "react";
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
@@ -47,3 +49,16 @@ export function onFullscreenChange(handler: () => void): () => void {
document.removeEventListener("webkitfullscreenchange", handler);
};
}
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
const fsServerSnapshot = () => null;
/** The fullscreen element as reactive state, for code that must RENDER differently while something
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
* merely react to the transition should keep using `onFullscreenChange` directly. */
export function useFullscreenElement(): HTMLElement | null {
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useRef } from "react";
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
// through this one stack — a layer acts on Escape only when it is topmost, and reads `anyAbove()` to
// know whether to defer. Because the decision is a stack lookup, not event-propagation order, the fix
// is robust to which listener happens to run first (the old stopPropagation was not).
//
// Notably this fixes the documented PlexPlayer bug: its menus register here, so the player sees a menu
// as a layer ABOVE it and defers, letting the menu's own Escape close it first — the player's
// auto-skip-cancel Escape then lands on the next press, instead of being swallowed by a blanket
// stopPropagation. (Popover-vs-popover ordering is still floating-ui's <FloatingTree>; this stack adds
// the cross-TYPE ordering the tree can't see.)
let stack: number[] = [];
let nextId = 1;
export function pushLayer(): number {
const id = nextId++;
stack.push(id);
return id;
}
export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
}
export function isTopmost(id: number): boolean {
return stack.length > 0 && stack[stack.length - 1] === id;
}
/** True when at least one layer sits above `id` — the "should I defer?" predicate for a layer with a
* richer key handler than a plain close (the players). */
export function anyLayerAbove(id: number): boolean {
const i = stack.indexOf(id);
return i !== -1 && i < stack.length - 1;
}
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
* to decide whether to defer (the players). */
export function useLayer(
active: boolean,
onEscape?: () => void,
): { isTopmost: () => boolean; anyAbove: () => boolean } {
const idRef = useRef<number | null>(null);
const escRef = useRef(onEscape);
escRef.current = onEscape;
useEffect(() => {
if (!active) return;
const id = pushLayer();
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
if (escRef.current) {
onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
};
document.addEventListener("keydown", onKey);
}
return () => {
if (onKey) document.removeEventListener("keydown", onKey);
removeLayer(id);
idRef.current = null;
};
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
}, [active]);
return {
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
anyAbove: () => idRef.current != null && anyLayerAbove(idRef.current),
};
}
+8
View File
@@ -134,6 +134,12 @@ export function DetailCustomizeMenu({
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
// any of them needs the platform to portal INTO a caller-named (scaled) container.
return (
<div className="relative shrink-0">
<button
@@ -168,6 +174,8 @@ export function DetailCustomizeMenu({
);
}
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
* so a plain button, not a menuitemradio. */
export function PrefToggle({
label,
on,
+43
View File
@@ -0,0 +1,43 @@
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api";
import { qk } from "./queryKeys";
import { notify } from "./notifications";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
* optimistic update injected (the manager maps over a ManagedChannel[]; the detail patches one
* ChannelDetail). Keeps the two call sites from drifting. */
export function useChannelTagActions(opts: {
/** Apply the optimistic tag change to whichever cache the caller owns. `attach` true = add. */
optimisticToggle: (channelId: string, tagId: number, attach: boolean) => void;
/** Called after the attach/detach lands (e.g. sync a sibling cache). Optional. */
onSuccess?: (channelId: string) => void;
/** Called if the attach/detach fails — revert by refetching the owning cache. */
onError: (channelId: string) => void;
}) {
const qc = useQueryClient();
const { t } = useTranslation();
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
opts.optimisticToggle(channelId, tagId, !hasTag);
const call = hasTag
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call.then(() => opts.onSuccess?.(channelId)).catch(() => opts.onError(channelId));
};
// Create a brand-new personal tag and attach it in one step (so tagging doesn't require the
// top-of-page "Manage tags" dialog first). Refresh the tag catalog so it appears everywhere.
const createAndAttachTag = (channelId: string, name: string) => {
api
.createTag({ name })
.then((tag) => {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
};
return { toggleTag, createAndAttachTag };
}
+14 -20
View File
@@ -1,14 +1,25 @@
import { useEffect, type RefObject } from "react";
import { useLayer } from "./layerStack";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
* element(s). The reusable half of the popover/dropdown pattern that several components each
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close).
*
* Escape is dispatched through the shared `layerStack` (R8 S3): the popover registers as a layer and
* closes only when it is topmost, so a Modal or another popover opened above it takes the Escape
* first, and a player below defers to it. This replaces the old blanket `stopPropagation` — which
* swallowed a player's own Escape (e.g. PlexPlayer's auto-skip-cancel) whenever any popover was open. */
export function useDismiss(
active: boolean,
onClose: () => void,
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
): void {
// Registration + topmost-only Escape.
useLayer(active, onClose);
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
// that lands outside every registered element.
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
@@ -16,29 +27,12 @@ export function useDismiss(
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Stop the same Escape from also reaching a player's window-level listener underneath —
// this document listener bubbles first, so one Escape closes only this popover, not the
// popover AND the player behind it. (U-C quick-fix.)
//
// KNOWN LIMITATION (accepted, → R8 popover-platform): because this is a blanket
// stopPropagation, a PlexPlayer settings/tracks menu open DURING an auto-skip countdown
// swallows the Escape that would otherwise ALSO cancel the skip — Escape closes the menu,
// and the skip proceeds. Properly fixing this needs the shared layer-registry R8 builds
// (topmost-only dispatch), not a broad stopPropagation.
e.stopPropagation();
onClose();
}
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
// stable and onClose is read fresh from this run's closure.
// Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
// onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}
+28 -20
View File
@@ -1,6 +1,7 @@
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
@@ -43,29 +44,36 @@ const root =
) : path === "/terms" ? (
<Terms />
) : path.startsWith("/watch/") ? (
<WatchPage />
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
<FloatingTree>
<WatchPage />
</FloatingTree>
) : (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
<FloatingTree>
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
</FloatingTree>
</ErrorBoundary>
</QueryClientProvider>
);