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, blocked: bool,
stored: int, stored: int,
deep_requested: bool, deep_requested: bool,
tag_ids: list[int],
) -> dict: ) -> dict:
return { return {
"id": channel.id, "id": channel.id,
@@ -257,6 +258,9 @@ def _channel_detail_dict(
"backfill_done": channel.backfill_done, "backfill_done": channel.backfill_done,
"from_explore": channel.from_explore, "from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None, "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 ).first() is not None
blocked = _is_blocked(db, user, channel_id) blocked = _is_blocked(db, user, channel_id)
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 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( return _channel_detail_dict(
channel, channel,
subscribed=subscribed, subscribed=subscribed,
@@ -304,6 +315,7 @@ def channel_detail(
blocked=blocked, blocked=blocked,
stored=int(stored), stored=int(stored),
deep_requested=bool(sub.deep_requested) if sub else False, 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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3", "@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -995,6 +996,59 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -5294,6 +5348,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "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/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.20",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"@tanstack/react-virtual": "^3.14.3", "@tanstack/react-virtual": "^3.14.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+39 -109
View File
@@ -1,28 +1,34 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api } from "../lib/api"; import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout"; import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
const MARGIN = 8;
const POP_W = 480; // the overlay's fixed width (px)
/** /**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About" * 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, * (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 * keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay * ["channel", id] react-query cache with the channel page, so the list stays light. The card is
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll * interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables. * 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 }) { export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim(); 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 [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({ const { data: ch } = useQuery({
queryKey: qk.channel(channelId), queryKey: qk.channel(channelId),
@@ -31,105 +37,29 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
staleTime: 5 * 60_000, 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>; if (!preview) return <span className="text-muted">—</span>;
return ( return (
<span <>
ref={ref} <span
onMouseEnter={show} ref={pop.refs.setReference}
onMouseLeave={scheduleHide} {...pop.getReferenceProps()}
onFocus={show} tabIndex={0}
onBlur={scheduleHide} className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
tabIndex={0} >
className="block max-w-[22rem] truncate text-muted cursor-help outline-none" {preview}
> </span>
{preview} <PopoverSurface
{open && popover={pop}
coords && className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
createPortal( >
<div {/* Full About once the detail loads; until then the list's truncated description. */}
ref={popRef} {ch ? (
onMouseEnter={cancelHide} <ChannelAboutContent ch={ch} />
onMouseLeave={scheduleHide} ) : (
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }} <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
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> </PopoverSurface>
</>
); );
} }
+85 -103
View File
@@ -1,16 +1,17 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName"; 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 // A small anchored menu for toggling a video's membership in the user's playlists, with inline
// video's membership in the user's playlists, with inline "new playlist" creation. // "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({ export default function AddToPlaylist({
videoId, videoId,
className, className,
@@ -21,60 +22,25 @@ export default function AddToPlaylist({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const fade = useScrollFade(); const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false); 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({ const membership = useQuery({
queryKey: qk.playlistMembership(videoId), queryKey: qk.playlistMembership(videoId),
queryFn: () => api.playlists(videoId), queryFn: () => api.playlists(videoId),
enabled: open, 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() { function refresh() {
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) }); qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
@@ -95,8 +61,7 @@ export default function AddToPlaylist({
} }
} }
async function createAndAdd(e: React.FormEvent) { async function createAndAdd() {
e.preventDefault();
const name = newName.trim(); const name = newName.trim();
if (!name || busy) return; if (!name || busy) return;
setBusy(true); setBusy(true);
@@ -117,8 +82,15 @@ export default function AddToPlaylist({
return ( return (
<> <>
<button <button
ref={triggerRef} ref={pop.refs.setReference}
onClick={toggleOpen} {...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")} title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")} aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"} 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" /> <ListPlus className="w-4 h-4" />
</button> </button>
{open && <PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
createPortal( {/* 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 <div
ref={panelRef} ref={fade.ref}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }} style={fade.style}
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl" aria-busy={busy}
onClick={(e) => e.stopPropagation()} 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> {lists.length === 0 && !membership.isLoading && (
<div <div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
ref={fade.ref} )}
style={fade.style} {lists.map((pl) => (
className="max-h-56 overflow-y-auto no-scrollbar" <PopoverItem
> key={pl.id}
{lists.length === 0 && !membership.isLoading && ( // A video can sit in several playlists at once — checkboxes, not one choice.
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div> role="menuitemcheckbox"
)} checked={pl.has_video}
{lists.map((pl) => ( onSelect={() => toggle(pl)}
<button // NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
key={pl.id} // <body> mid-request and permanently kill roving focus (useListNavigation listens on
onClick={() => toggle(pl)} // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
disabled={busy} // guarded without touching focus; `aria-busy` on the list conveys the pending state.
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" 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 {pl.has_video && <Check className="w-3 h-3" />}
className={`grid place-items-center w-4 h-4 rounded border ${ </span>
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border" <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" />
{pl.has_video && <Check className="w-3 h-3" />} )}
</span> </PopoverItem>
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span> ))}
{(pl.source === "youtube" || pl.yt_playlist_id) && ( </div>
<Youtube className="w-3 h-3 shrink-0 text-muted" /> <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" />
</button> <input
))} value={newName}
</div> onChange={(e) => setNewName(e.target.value)}
<form onKeyDown={(e) => {
onSubmit={createAndAdd} // Escape bubbles to the popover's dismiss; every other key is text editing here and
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5" // must not reach the surrounding menu's list navigation.
> if (e.key === "Escape") return;
<Plus className="w-3.5 h-3.5 text-muted shrink-0" /> e.stopPropagation();
<input if (e.key === "Enter") {
value={newName} e.preventDefault();
onChange={(e) => setNewName(e.target.value)} 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" }}
/> placeholder={t("playlists.newPlaylist")}
</form> 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>, />
document.body, </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 { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; 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 type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format"; 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 // 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 // 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")), 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 onUnsubscribe = async () => {
const ok = await confirm({ const ok = await confirm({
title: t("channel.unsubTitle"), title: t("channel.unsubTitle"),
@@ -411,6 +438,19 @@ export default function ChannelPage({
{t("channel.subscribe")} {t("channel.subscribe")}
</button> </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> </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 { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -12,17 +12,14 @@ import {
History, History,
LayoutGrid, LayoutGrid,
Pencil, Pencil,
Plus,
RefreshCw, RefreshCw,
Table, Table,
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } 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 { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns"; import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@@ -30,7 +27,8 @@ import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid"; import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState"; 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 ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl"; import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager"; 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 // 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. // made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
const toggleTag = (id: string, tagId: number, hasTag: boolean) => { // attach/detach + create-and-attach flow is shared with the channel detail page.
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => const { toggleTag, createAndAttachTag } = useChannelTagActions({
(old ?? []).map((ch) => optimisticToggle: (id, tagId, attach) =>
ch.id === id qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
? { (old ?? []).map((ch) =>
...ch, ch.id === id
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId], ? {
} ...ch,
: ch, tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
}
: ch,
),
), ),
); // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
}; onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => { onSuccess: (r: { subscriptions?: number }) => {
@@ -453,17 +455,21 @@ export default function Channels() {
key: "tags", key: "tags",
header: t("channels.cols.tags"), header: t("channels.cols.tags"),
cardLabel: false, 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: { filter: {
kind: "multi", kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
}, },
render: (c) => ( render: (c) => (
<TagsCell <ChannelTagsControl
c={c}
userTags={userTags} userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} attachedIds={c.tag_ids}
onTagClick={onFilterByTag} 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 ( return (
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge <SyncBadge
ok={c.recent_synced} ok={c.recent_synced}
label={t("channels.row.recent")} 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({ function ActionsCell({
c, c,
isAdmin, isAdmin,
+63 -122
View File
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { Check, Globe } from "lucide-react"; import { Check, Globe } from "lucide-react";
import { LANGUAGES, type LangCode } from "../i18n"; import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Compact language picker (globe + current code). Presentational: the parent decides what // Compact language picker (globe + current code). Presentational: the parent decides what
// changing the language does (set it; persist server-side when signed in). // 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 // Two variants, now one implementation on the R8 popover platform: "header" opens below the pill
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and // (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
// anchored to the right + above the button, escaping the nav's backdrop-filter which would // nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
// otherwise trap an absolutely-positioned popover). // 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({ export default function LanguageSwitcher({
value, value,
onChange, onChange,
@@ -22,128 +23,68 @@ export default function LanguageSwitcher({
variant?: "header" | "rail"; variant?: "header" | "rail";
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const currentIdx = Math.max(
const btnRef = useRef<HTMLButtonElement | null>(null); 0,
const panelRef = useRef<HTMLDivElement | null>(null); LANGUAGES.findIndex((l) => l.code === value),
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 current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
const rail = variant === "rail";
if (variant === "rail") { const pop = useAnchoredPopover({
return ( open,
<> onOpenChange: setOpen,
<button placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
ref={btnRef} offset: rail ? 8 : 4,
onClick={toggleRail} // Single choice: opening lands on the language already in use.
title={current.label} selectedIndex: currentIdx,
aria-label={current.label} });
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
> return (
<Globe className="w-5 h-5" /> <>
{/* Current-language badge (HU/EN) so the active language reads at a glance without <button
opening the menu. The ring matches the page background to detach it from the icon. */} 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"> <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()} {current.code.toUpperCase()}
</span> </span>
</button> ) : (
{open && <span className="text-xs font-semibold uppercase">{current.code}</span>
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>
</button> </button>
{open && ( <PopoverSurface
<div popover={pop}
className={`glass-menu absolute ${ className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
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) => (
> <PopoverItem
{LANGUAGES.map((l) => ( key={l.code}
<button role="menuitemradio"
key={l.code} checked={l.code === value}
onClick={() => { onSelect={() => {
onChange(l.code); onChange(l.code);
setOpen(false); 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" 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" />} <span>{l.label}</span>
</button> {l.code === value && <Check className="w-4 h-4 text-accent" />}
))} </PopoverItem>
</div> ))}
)} </PopoverSurface>
</div> </>
); );
} }
+13 -18
View File
@@ -3,20 +3,15 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useBackToClose } from "../lib/history"; 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 // Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath. // shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
let modalStack: number[] = []; // dismisses just the error and returns to the editor underneath, and a player below defers to it
let nextModalId = 1; // (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
/** How many <Modal>s are currently open. The players (which use their own window-level Escape // download dialog) would otherwise open invisibly.
* 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.
export default function Modal({ export default function Modal({
title, title,
onClose, onClose,
@@ -31,6 +26,7 @@ export default function Modal({
const { t } = useTranslation(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose); useBackToClose(onClose);
const fsRoot = useFullscreenElement();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a // 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 // 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); const onCloseRef = useRef(onClose);
onCloseRef.current = onClose; onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
const id = nextModalId++; const id = pushLayer();
modalStack.push(id);
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) { if (e.key === "Escape" && isTopmost(id)) {
e.stopPropagation(); e.stopPropagation();
onCloseRef.current(); onCloseRef.current();
} }
@@ -54,7 +49,7 @@ export default function Modal({
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
modalStack = modalStack.filter((x) => x !== id); removeLayer(id);
document.body.style.overflow = prev; document.body.style.overflow = prev;
}; };
}, []); }, []);
@@ -88,6 +83,6 @@ export default function Modal({
<div className="px-5 pb-5">{children}</div> <div className="px-5 pb-5">{children}</div>
</div> </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 { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
@@ -25,6 +24,7 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar"; import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // 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 // 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 expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false); const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null); // The account panel holds mixed content with its own tab order (profile, account switches, two
const acctPanelRef = useRef<HTMLDivElement | null>(null); // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// clicks fall through to the controls behind it. // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); // comes from the platform instead of a pair of document listeners.
const acctPop = useAnchoredPopover({
function toggleAccount() { open: acctOpen,
if (!acctOpen) { onOpenChange: setAcctOpen,
const r = acctBtnRef.current?.getBoundingClientRect(); mode: "dialog",
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); role: "dialog",
} placement: "right-end",
setAcctOpen((o) => !o); offset: 8,
} });
// 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]);
async function logout() { async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then // 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>} {!slim && <span className="truncate">{t("header.account.settings")}</span>}
</button> </button>
<div className="relative"> <div>
<button <button
ref={acctBtnRef} ref={acctPop.refs.setReference}
onClick={toggleAccount} {...acctPop.getReferenceProps()}
title={slim ? `${name} · ${roleLabel}` : undefined} title={slim ? `${name} · ${roleLabel}` : undefined}
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`} className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
> >
@@ -359,79 +338,75 @@ export default function NavSidebar({
)} )}
</button> </button>
{acctOpen && <PopoverSurface
createPortal( popover={acctPop}
<div ariaLabel={name}
ref={acctPanelRef} className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
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
<div className="flex items-center gap-3 pb-3 border-b border-border"> src={me.avatar_url}
<AvatarImg fallback={name}
src={me.avatar_url} className="w-10 h-10 rounded-full text-sm shrink-0"
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="min-w-0"> <div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-sm font-semibold truncate">{name}</div> </div>
<div className="text-xs text-muted truncate">{me.email}</div> </div>
</div> {me.role === "admin" && (
</div> <div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
{me.role === "admin" && ( <Shield className="w-3.5 h-3.5" />
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent"> {t("header.account.admin")}
<Shield className="w-3.5 h-3.5" /> </div>
{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,
)} )}
{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>
</div> </div>
</nav> </nav>
+189 -196
View File
@@ -27,7 +27,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; 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 DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api"; import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube"; 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 AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; 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)? /** 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 * 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 * 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 // Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto // upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still
// can't clip it. A small close grace lets the mouse travel title → popover. // 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); const [showDesc, setShowDesc] = useState(false);
// `bottom` anchors the popover just above the title so it grows upward (over the const descPop = useAnchoredPopover({
// player) instead of downward off-screen / behind the OS taskbar. open: showDesc,
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>( onOpenChange: setShowDesc,
null, mode: "hover",
); placement: "top-start",
const titleRef = useRef<HTMLSpanElement | null>(null); offset: 8,
const closeTimer = useRef<number | undefined>(undefined); hoverDelay: { open: 400, close: 150 },
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 detail = useQuery({ const detail = useQuery({
queryKey: qk.videoDetail(currentVideoId), queryKey: qk.videoDetail(currentVideoId),
queryFn: () => api.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId),
@@ -490,12 +573,28 @@ export default function PlayerModal({
staleTime: 5 * 60_000, 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. // 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 // 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). // we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { 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. // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return; if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player. // 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); setMaximized(false);
return; 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(); onClose();
return; return;
} }
@@ -560,8 +656,6 @@ export default function PlayerModal({
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow; document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current);
window.clearTimeout(openTimer.current);
window.clearTimeout(volTimerRef.current); window.clearTimeout(volTimerRef.current);
// Flush a debounced volume write — closing the player right after a wheel spin (the normal // 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. // 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 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 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; 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 the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its
fullscreen — their popovers portal to <body>, outside the fullscreen element. */} 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) && ( {(maximized || isFullscreen) && (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -884,76 +979,30 @@ export default function PlayerModal({
> >
{hasQueue && ( {hasQueue && (
<> <>
<button <PlayerTransportControls
onClick={goPrev} variant="docked"
disabled={index === 0} index={index}
title={`${t("player.previous")} · Shift+←`} queueLength={queue!.length}
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" autoMode={autoMode}
> loopMode={loopMode}
<SkipBack className="h-4 w-4" /> onPrev={goPrev}
<span className="hidden sm:inline">{t("player.previous")}</span> onNext={goNext}
</button> onCycleAuto={cycleAuto}
<span className="tabular-nums text-white/70"> onCycleLoop={cycleLoop}
{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>
<span className="h-4 w-px bg-white/20" /> <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 <AddToPlaylist
videoId={active.id} 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" 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 <DownloadButton
videoId={active.id} videoId={active.id}
title={active.title} title={active.title}
@@ -1051,61 +1100,17 @@ export default function PlayerModal({
{hasQueue && ( {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"> <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 <PlayerTransportControls
onClick={goPrev} variant="modal"
disabled={index === 0} index={index}
title={`${t("player.previous")} · Shift+←`} queueLength={queue!.length}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" autoMode={autoMode}
> loopMode={loopMode}
<SkipBack className="w-4 h-4" /> {t("player.previous")} onPrev={goPrev}
</button> onNext={goNext}
<span className="text-muted tabular-nums"> onCycleAuto={cycleAuto}
{index + 1} / {queue!.length} onCycleLoop={cycleLoop}
</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>
</div> </div>
)} )}
@@ -1115,10 +1120,9 @@ export default function PlayerModal({
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug"> <h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{/* Hover target is the text itself (inline), not the whole row. */} {/* Hover target is the text itself (inline), not the whole row. */}
<span <span
ref={titleRef} ref={descPop.refs.setReference}
{...descPop.getReferenceProps()}
className="cursor-default" className="cursor-default"
onMouseEnter={scheduleOpenDesc}
onMouseLeave={scheduleCloseDesc}
> >
{navigated ? (liveData?.title ?? t("player.loading")) : active.title} {navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</span> </span>
@@ -1133,43 +1137,32 @@ export default function PlayerModal({
{t("player.back")} {t("player.back")}
</button> </button>
)} )}
{showDesc && <PopoverSurface
descRect && popover={descPop}
createPortal( 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 <div
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4" ref={descFade.ref}
style={{ style={descFade.style}
left: descRect.left, className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDescNow}
onMouseLeave={scheduleCloseDesc}
> >
<div className="text-xs uppercase tracking-wide text-muted mb-2"> {renderDescription(detail.data.description, {
{t("player.description")} currentId: currentVideoId,
</div> onSeek: seekTo,
{detail.isLoading ? ( onLoadVideo: loadVideo,
<div className="text-sm text-muted">{t("player.loading")}</div> t,
) : detail.data?.description ? ( })}
<div </div>
ref={descFade.ref} ) : (
style={descFade.style} <div className="text-sm text-muted">{t("player.noDescription")}</div>
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,
)} )}
</PopoverSurface>
<button <button
data-testid="player-close" data-testid="player-close"
onClick={onClose} onClick={onClose}
+17 -3
View File
@@ -46,7 +46,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history"; 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. // The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo")); const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -808,6 +808,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack(); handleBack();
}, [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 -------------------------------------------------------------------------------- // --- keyboard --------------------------------------------------------------------------------
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@@ -874,8 +886,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else handleBack(); else handleBack();
break; break;
case "Escape": case "Escape":
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C). // Defer to any layer opened above the player (Modal or anchored popover) — let it take the
if (modalCount() > 0) break; // 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(); if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) { else if (menuOpenRef.current) {
setMenuOpen(false); setMenuOpen(false);
+54 -59
View File
@@ -1,12 +1,11 @@
import { useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
import { formatViews } from "../lib/format"; 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* // 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 // 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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const chipRef = useRef<HTMLButtonElement | null>(null); // A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
const popRef = useRef<HTMLDivElement | null>(null); // popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter // stacking and positioning.
// would otherwise trap its stacking and positioning. const pop = useAnchoredPopover({
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }); open,
useDismiss(open, () => setOpen(false), [chipRef, popRef]); onOpenChange: setOpen,
mode: "dialog",
role: "dialog",
placement: "bottom-end",
offset: 8,
});
const { data } = useQuery({ const { data } = useQuery({
queryKey: qk.myStatus(), queryKey: qk.myStatus(),
queryFn: api.myStatus, queryFn: api.myStatus,
@@ -106,16 +110,11 @@ export default function SyncStatus({
return ( return (
<div className="pointer-events-auto shrink-0"> <div className="pointer-events-auto shrink-0">
<button <button
ref={chipRef} ref={pop.refs.setReference}
onClick={() => { {...pop.getReferenceProps()}
const r = chipRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
setOpen((o) => !o);
}}
// Short label only: the popover already explains the numbers, and the long one overflowed // Short label only: the popover already explains the numbers, and the long one overflowed
// the viewport edge from here. // the viewport edge from here.
title={countsText} 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" className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
> >
{active ? ( {active ? (
@@ -129,50 +128,46 @@ export default function SyncStatus({
)} )}
</button> </button>
{open && <PopoverSurface
createPortal( popover={pop}
<div ariaLabel={countsText}
ref={popRef} className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
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")}
<div className="flex items-center gap-1.5 pb-2 border-b border-border/60"
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>
<Database className="w-3.5 h-3.5 shrink-0" /> <span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
<span> {t("header.sync.yours")}
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "} <span className="opacity-40"> / </span>
{t("header.sync.yours")} {formatViews(data.total_videos)} {t("header.sync.total")}
<span className="opacity-40"> / </span> </span>
{formatViews(data.total_videos)} {t("header.sync.total")} </div>
</span> {showMain && (
</div> <div className="flex items-center justify-between gap-1.5 pt-2">
{showMain && ( <span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
<div className="flex items-center justify-between gap-1.5 pt-2"> {pauseBtn}
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span> </div>
{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,
)} )}
{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> </div>
); );
} }
+38 -43
View File
@@ -1,5 +1,4 @@
import { useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -9,10 +8,12 @@ import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal"; import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; 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 // 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 // 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({ function TagRow({
tag, tag,
channels, channels,
@@ -29,25 +30,20 @@ function TagRow({
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(tag.name); const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade(); const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => { const commit = () => {
const v = name.trim(); const v = name.trim();
if (v && v !== tag.name) onRename(v); if (v && v !== tag.name) onRename(v);
else setName(tag.name); else setName(tag.name);
}; };
const openPop = () => { const pop = useAnchoredPopover({
window.clearTimeout(closeTimer.current); open: open && channels.length > 0,
if (channels.length === 0) return; onOpenChange: setOpen,
const r = countRef.current?.getBoundingClientRect(); mode: "hover",
if (r) setPos({ left: r.right + 8, top: r.top }); placement: "right-start",
setOpen(true); offset: 8,
}; hoverDelay: { open: 0, close: 150 },
const closeSoon = () => { });
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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" 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 <span
ref={countRef} ref={pop.refs.setReference}
onMouseEnter={openPop} {...pop.getReferenceProps()}
onMouseLeave={closeSoon}
className={`text-xs text-muted tabular-nums shrink-0 ${ className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2" ? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
@@ -77,30 +72,30 @@ function TagRow({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
{open && <PopoverSurface
createPortal( popover={pop}
<div className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
ref={popFade.ref} >
onMouseEnter={openPop} <div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
onMouseLeave={closeSoon} {t("tagManager.onChannels")}
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }} </div>
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]" {/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
> <div
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1"> ref={popFade.ref}
{t("tagManager.onChannels")} style={popFade.style}
</div> className="max-h-72 overflow-y-auto no-scrollbar"
{channels.map((ch) => ( >
<button {channels.map((ch) => (
key={ch.id} <button
onClick={() => onPickChannel(ch.title)} key={ch.id}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition" 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> {ch.title}
))} </button>
</div>, ))}
document.body, </div>
)} </PopoverSurface>
</div> </div>
); );
} }
+30 -73
View File
@@ -1,16 +1,13 @@
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { hintsEnabled, subscribeHints } from "../lib/hints"; import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom"; 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 /** 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 * app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */ * 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({ export default function Tooltip({
hint, hint,
side = "top", side = "top",
@@ -21,74 +18,34 @@ export default function Tooltip({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef<HTMLSpanElement>(null); const [open, setOpen] = useState(false);
const tipRef = useRef<HTMLDivElement>(null); const pop = useAnchoredPopover({
const [coords, setCoords] = useState<Coords | null>(null); open: open && enabled && !!hint,
onOpenChange: setOpen,
function show() { mode: "hover",
const el = ref.current; placement: side,
if (!el) return; offset: 8,
const r = el.getBoundingClientRect(); // Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent
// Prefer the requested side; flip to bottom if there's no room above. // default suits interactive cards but makes a fast sweep across an icon row surface no hints.
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top"; hoverDelay: { open: 0, close: 0 },
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]);
if (!enabled || !hint) return <>{children}</>; if (!enabled || !hint) return <>{children}</>;
return ( return (
<span <>
ref={ref} <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
onMouseEnter={show} {children}
onMouseLeave={hide} </span>
onFocus={show} {/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
onBlur={hide} overlaps while the hover-close delay runs out. */}
className="inline-flex" <PopoverSurface
> popover={pop}
{children} passthrough
{coords && 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]"
createPortal( >
<div {hint}
ref={tipRef} </PopoverSurface>
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>
); );
} }
+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 { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { useDismiss } from "../lib/useDismiss"; import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
export interface ViewOption<T extends string> { export interface ViewOption<T extends string> {
id: T; 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 // 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. // 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 // This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine.
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the // It now rides the R8 popover platform like every other menu, which supplies the same model
// menu unmounts under the focused item, which would otherwise strand focus on <body>). // (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>({ export default function ViewSwitcher<T extends string>({
value, value,
options, options,
@@ -34,19 +36,6 @@ export default function ViewSwitcher<T extends string>({
labelClassName?: string; labelClassName?: string;
}) { }) {
const [open, setOpen] = useState(false); 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 // 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. // 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, 0,
options.findIndex((o) => o.id === value), 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 const pop = useAnchoredPopover({
// and focus the wrong item for one render before correcting. open,
function openMenu() { onOpenChange: setOpen,
setFocusIdx(currentIdx); placement: "bottom-end",
setOpen(true); // Opening a "pick one of these modes" menu should land on the mode that is already picked.
} selectedIndex: currentIdx,
// 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 current = options[currentIdx]; const current = options[currentIdx];
if (!current) return null; if (!current) return null;
const CurrentIcon = current.icon; 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 ( return (
<div className="relative"> <>
<button <button
ref={btnRef} ref={pop.refs.setReference}
onClick={() => (open ? close() : openMenu())} {...pop.getReferenceProps()}
aria-haspopup="menu"
aria-expanded={open}
// The aria-label carries the mode's name at every size — the visible text below is a // 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. // nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`} 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" : ""}`} className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/> />
</button> </button>
{open && ( <PopoverSurface
<div popover={pop}
ref={menuRef} ariaLabel={label}
role="menu" className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
aria-label={label} >
onKeyDown={onMenuKeyDown} {/* The modes are one exclusive choice, so the radio items need a group to scope their
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]" checked-ness to — otherwise they read as independent checkable items. */}
> <div role="group">
{/* The modes are one exclusive choice, so the radio items need a group to scope their {options.map((o) => {
checked-ness to — otherwise they read as independent checkable items. */} const Icon = o.icon;
<div role="group"> const on = o.id === value;
{options.map((o, i) => { return (
const Icon = o.icon; <PopoverItem
const on = o.id === value; key={o.id}
return ( role="menuitemradio"
<button checked={on}
key={o.id} onSelect={() => {
ref={(el) => { onChange(o.id);
itemRefs.current[i] = el; setOpen(false);
}} }}
role="menuitemradio" label={o.label}
aria-checked={on} 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)] ${
tabIndex={i === rovingIdx ? 0 : -1} on ? "text-accent" : "text-fg hover:bg-card"
onClick={() => { }`}
onChange(o.id); >
close(); <Icon className="w-4 h-4 shrink-0" />
}} <span className="flex-1 truncate">{o.label}</span>
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${ {on && <Check className="w-3.5 h-3.5 shrink-0" />}
on ? "text-accent" : "text-fg hover:bg-card" </PopoverItem>
}`} );
> })}
<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>
</div> </div>
)} </PopoverSurface>
</div> </>
); );
} }
+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 { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; 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 { useScrollFade } from "../lib/useScrollFade";
import { type SortState } from "../lib/columnSort"; 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 // MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a
// component type on every render: React unmounted and remounted the popover on each keystroke, so // NEW component type on every render: React unmounted and remounted the popover on each keystroke,
// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The // 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". // `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, col,
value, value,
onApply, onApply,
panelRef, open,
onOpenChange,
}: { }: {
col: Column<T>; col: Column<T>;
value: string | string[] | undefined; value: string | string[] | undefined;
onApply: (key: string, value: string | string[]) => void; onApply: (key: string, value: string | string[]) => void;
panelRef: RefObject<HTMLDivElement>; open: boolean;
onOpenChange: (open: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const f = col.filter!; 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 ( return (
<div <>
ref={panelRef} <button
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]" ref={pop.refs.setReference}
> {...pop.getReferenceProps()}
{f.kind === "text" && ( aria-label={t("datatable.filter")}
<input // Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
autoFocus className={`-m-1.5 p-1.5 transition ${
value={(value as string) ?? ""} filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
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" <ListFilter className="w-3.5 h-3.5" />
/> </button>
)} <PopoverSurface
{f.kind === "select" && ( popover={pop}
<div className="flex flex-col gap-0.5"> ariaLabel={col.header}
<button initialFocus={isText ? inputRef : undefined}
onClick={() => onApply(col.key, "")} className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
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" {f.kind === "text" && (
}`} <input
> ref={inputRef}
{t("datatable.all")} value={(value as string) ?? ""}
</button> onChange={(e) => onApply(col.key, e.target.value)}
{f.options.map((o) => ( placeholder={col.header}
<button className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
key={o.value} />
onClick={() => onApply(col.key, o.value)} )}
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${ {f.kind === "select" && (
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card" <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} {t("datatable.all")}
</button> </PopoverItem>
))} {f.options.map((o) => (
</div> <PopoverItem
)}
{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
key={o.value} key={o.value}
onClick={() => role="menuitemradio"
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value]) checked={value === o.value}
} onSelect={() => onApply(col.key, o.value)}
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${ label={o.label}
on ? "text-fg" : "text-muted hover:bg-card" 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 {o.label}
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${ </PopoverItem>
on ? "bg-accent border-accent text-accent-fg" : "border-border" ))}
</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
</span> className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
{o.label} on ? "bg-accent border-accent text-accent-fg" : "border-border"
</button> }`}
); >
})} {on && <X className="w-2.5 h-2.5" />}
</FilterOptionList> </span>
)} {o.label}
{isActive(value) && ( </PopoverItem>
<button );
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")} })}
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition" </FilterOptionList>
> )}
{t("datatable.clear")} {filterOn && (
</button> <button
)} onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
</div> 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; filters: FilterMap;
onFilter: (key: string, value: string | string[]) => void; 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 [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 ( 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-['']"> <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)]"> <tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => { {columns.map((col) => {
const isSorted = sort?.key === col.key; const isSorted = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
return ( return (
<th <th
key={col.key} key={col.key}
@@ -267,33 +293,15 @@ export function ColumnHead<T>({
<span>{col.header}</span> <span>{col.header}</span>
)} )}
{col.filter && ( {col.filter && (
<button <FilterFunnel
// Captured at open time rather than bound with `ref`, so it survives the close col={col}
// (a `ref` scoped to the open column nulls out before focus can go back). value={filters[col.key]}
onClick={(e) => { onApply={onFilter}
triggerRef.current = e.currentTarget; open={openFilter === col.key}
setOpenFilter((o) => (o === col.key ? null : col.key)); onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
}} />
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>
)} )}
</span> </span>
{openFilter === col.key && col.filter && (
<FilterPopover
col={col}
value={filters[col.key]}
onApply={onFilter}
panelRef={panelRef}
/>
)}
</th> </th>
); );
})} })}
+7 -3
View File
@@ -46,7 +46,11 @@
"tags": { "tags": {
"yourTags": "Your tags", "yourTags": "Your tags",
"manage": "Manage 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…", "loading": "Loading channels…",
"empty": "No 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.", "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel", "resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.", "resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"fullHistoryQueued": "full history queued", "fullHistoryQueued": "queued",
"fullHistoryComing": "full history coming", "fullHistoryComing": "coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "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.", "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.", "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": { "tags": {
"yourTags": "Címkéid", "yourTags": "Címkéid",
"manage": "Címkék kezelése", "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…", "loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.", "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.", "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", "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.", "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", "fullHistoryQueued": "sorban",
"fullHistoryComing": "teljes előzmény úton", "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.", "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.", "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.", "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; backfill_done: boolean;
from_explore: boolean; from_explore: boolean;
details_synced: boolean; details_synced: boolean;
tag_ids: number[];
} }
export interface ExploreResult { export interface ExploreResult {
+1 -1
View File
@@ -12,7 +12,7 @@ export interface Tag {
export const tagsApi = { export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"), 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) }), req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) => updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), 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. // The browser's Fullscreen API, WebKit included.
// //
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no // 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); 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 btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]); 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 ( return (
<div className="relative shrink-0"> <div className="relative shrink-0">
<button <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({ export function PrefToggle({
label, label,
on, 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 { useEffect, type RefObject } from "react";
import { useLayer } from "./layerStack";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given /** 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 * 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 * 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( export function useDismiss(
active: boolean, active: boolean,
onClose: () => void, onClose: () => void,
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[], refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
): void { ): 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(() => { useEffect(() => {
if (!active) return; if (!active) return;
const list = Array.isArray(refs) ? refs : [refs]; const list = Array.isArray(refs) ? refs : [refs];
@@ -16,29 +27,12 @@ export function useDismiss(
const target = e.target as Node; const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose(); 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("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => { return () => {
document.removeEventListener("mousedown", onDown); document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
}; };
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are // Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
// stable and onClose is read fresh from this run's closure. // onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]); }, [active]);
} }
+28 -20
View File
@@ -1,6 +1,7 @@
import React, { lazy, Suspense } from "react"; import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider"; import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider"; import { NavigationProvider } from "./components/NavigationProvider";
@@ -43,29 +44,36 @@ const root =
) : path === "/terms" ? ( ) : path === "/terms" ? (
<Terms /> <Terms />
) : path.startsWith("/watch/") ? ( ) : path.startsWith("/watch/") ? (
<WatchPage /> // FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
<FloatingTree>
<WatchPage />
</FloatingTree>
) : ( ) : (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ErrorBoundary> <ErrorBoundary>
<ConfirmProvider> {/* One tree for the whole app: every anchored popover registers as a node so Escape and
<NavigationProvider> outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
<FeedFiltersProvider> <FloatingTree>
<PlaylistsProvider> <ConfirmProvider>
<PlexProvider> <NavigationProvider>
<ChannelsProvider> <FeedFiltersProvider>
<FeedViewProvider> <PlaylistsProvider>
<PrefsProvider> <PlexProvider>
<WizardProvider> <ChannelsProvider>
<App /> <FeedViewProvider>
</WizardProvider> <PrefsProvider>
</PrefsProvider> <WizardProvider>
</FeedViewProvider> <App />
</ChannelsProvider> </WizardProvider>
</PlexProvider> </PrefsProvider>
</PlaylistsProvider> </FeedViewProvider>
</FeedFiltersProvider> </ChannelsProvider>
</NavigationProvider> </PlexProvider>
</ConfirmProvider> </PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
</FloatingTree>
</ErrorBoundary> </ErrorBoundary>
</QueryClientProvider> </QueryClientProvider>
); );