diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 3325d509..2bb2c1ba 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -230,6 +230,7 @@ def _channel_detail_dict( blocked: bool, stored: int, deep_requested: bool, + tag_ids: list[int], ) -> dict: return { "id": channel.id, @@ -257,6 +258,9 @@ def _channel_detail_dict( "backfill_done": channel.backfill_done, "from_explore": channel.from_explore, "details_synced": channel.details_synced_at is not None, + # This user's personal tag links for the channel (same shape as the manager list), so the + # channel page can offer the same tag editor next to Subscribe. + "tag_ids": tag_ids, } @@ -297,6 +301,13 @@ def channel_detail( ).first() is not None blocked = _is_blocked(db, user, channel_id) stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0 + tag_ids = list( + db.scalars( + select(ChannelTag.tag_id).where( + ChannelTag.user_id == user.id, ChannelTag.channel_id == channel_id + ) + ).all() + ) return _channel_detail_dict( channel, subscribed=subscribed, @@ -304,6 +315,7 @@ def channel_detail( blocked=blocked, stored=int(stored), deep_requested=bool(sub.deep_requested) if sub else False, + tag_ids=tag_ids, ) diff --git a/backend/tests/test_channel_tags.py b/backend/tests/test_channel_tags.py new file mode 100644 index 00000000..025d7bba --- /dev/null +++ b/backend/tests/test_channel_tags.py @@ -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] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 301f023b..18c044c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.27.20", "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", @@ -995,6 +996,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -5294,6 +5348,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", diff --git a/frontend/package.json b/frontend/package.json index f29efae6..dfa964a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.27.20", "@tanstack/react-query": "^5.51.0", "@tanstack/react-virtual": "^3.14.3", "clsx": "^2.1.1", diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index e8891785..68ca37ea 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -1,28 +1,34 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api } from "../lib/api"; import ChannelAboutContent from "./ChannelAbout"; - -const MARGIN = 8; -const POP_W = 480; // the overlay's fixed width (px) +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; /** * A channel-description cell: one truncated line in the table, revealing the FULL channel "About" * (the same content as the detail page's About tab — description, links, country, language, topics, - * keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing - * the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay - * is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll - * (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables. + * keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the + * ["channel", id] react-query cache with the channel page, so the list stays light. The card is + * interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the + * gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery + * tables. + * + * The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel + * properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration. */ export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) { const preview = (text ?? "").trim(); - const ref = useRef(null); - const popRef = useRef(null); - const hideTimer = useRef | null>(null); const [open, setOpen] = useState(false); - const [coords, setCoords] = useState<{ left: number; top: number } | null>(null); + + const pop = useAnchoredPopover({ + open: open && !!preview, + onOpenChange: setOpen, + mode: "hover", + offset: 6, + // Pop instantly like the old cell did; the close grace lets the cursor reach the card. + hoverDelay: { open: 0, close: 160 }, + }); const { data: ch } = useQuery({ queryKey: qk.channel(channelId), @@ -31,105 +37,29 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch staleTime: 5 * 60_000, }); - function place() { - const el = ref.current; - if (!el) return; - const r = el.getBoundingClientRect(); - setCoords({ - left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN), - top: r.bottom + 6, - }); - } - function show() { - if (hideTimer.current) { - clearTimeout(hideTimer.current); - hideTimer.current = null; - } - place(); - setOpen(true); - } - // Small grace period so the cursor can travel the gap from the cell to the overlay without it - // vanishing — the overlay's own mouseenter cancels it. - function scheduleHide() { - if (hideTimer.current) clearTimeout(hideTimer.current); - hideTimer.current = setTimeout(() => setOpen(false), 160); - } - function cancelHide() { - if (hideTimer.current) { - clearTimeout(hideTimer.current); - hideTimer.current = null; - } - } - - useEffect( - () => () => { - if (hideTimer.current) clearTimeout(hideTimer.current); - }, - [], - ); - - // Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT - // when the scroll happens inside the overlay itself (it is scrollable). - useEffect(() => { - if (!open) return; - const onScroll = (e: Event) => { - if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return; - setOpen(false); - }; - const onResize = () => setOpen(false); - window.addEventListener("scroll", onScroll, true); - window.addEventListener("resize", onResize); - return () => { - window.removeEventListener("scroll", onScroll, true); - window.removeEventListener("resize", onResize); - }; - }, [open]); - - // Flip above the anchor if the overlay would run off the bottom of the viewport. - useLayoutEffect(() => { - const pop = popRef.current; - const el = ref.current; - if (!open || !coords || !pop || !el) return; - const r = el.getBoundingClientRect(); - const h = pop.offsetHeight; - if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) { - const top = r.top - 6 - h; - if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c)); - } - }, [open, coords, ch]); - if (!preview) return —; return ( - - {preview} - {open && - coords && - createPortal( -
- {/* Full About once the detail loads; until then the list's truncated description. */} - {ch ? ( - - ) : ( -

{preview}

- )} -
, - document.body, + <> + + {preview} + + + {/* Full About once the detail loads; until then the list's truncated description. */} + {ch ? ( + + ) : ( +

{preview}

)} -
+ + ); } diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 8d8fc581..ec0199bb 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -1,16 +1,17 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; -import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { playlistName } from "../lib/playlistName"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; -// A small popover (portaled to body, so the feed grid can't clip it) for toggling a -// video's membership in the user's playlists, with inline "new playlist" creation. +// A small anchored menu for toggling a video's membership in the user's playlists, with inline +// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style +// toggles plus a free-text creator, so it follows that file's two conventions — typeahead OFF and +// the input swallowing non-Escape keys, since character and arrow keys belong to the text field. export default function AddToPlaylist({ videoId, className, @@ -21,60 +22,25 @@ export default function AddToPlaylist({ const { t } = useTranslation(); const qc = useQueryClient(); const fade = useScrollFade(); - const triggerRef = useRef(null); - const panelRef = useRef(null); const [open, setOpen] = useState(false); - const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const [newName, setNewName] = useState(""); const [busy, setBusy] = useState(false); + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + offset: 6, + // The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the + // roving list so they don't steal the caret. + hostsInput: true, + }); + const membership = useQuery({ queryKey: qk.playlistMembership(videoId), queryFn: () => api.playlists(videoId), enabled: open, }); - // Re-place once the panel is actually rendered (so we know its real height) and whenever - // its content — hence height — changes, so the flip-above decision uses the true size. - useLayoutEffect(() => { - if (open) place(); - }, [open, membership.data]); - - function place() { - const r = triggerRef.current?.getBoundingClientRect(); - if (!r) return; - const width = 240; - const margin = 8; - // Use the panel's real height once it's mounted; estimate on the first open. - const h = panelRef.current?.offsetHeight ?? 300; - let top = r.bottom + 6; - if (top + h > window.innerHeight - margin) { - // Not enough room below — open upward, clamped to the viewport. - top = Math.max(margin, r.top - h - 6); - } - const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin); - setCoords({ top: Math.round(top), left: Math.round(left) }); - } - - function toggleOpen(e: React.MouseEvent) { - e.preventDefault(); - e.stopPropagation(); - if (!open) place(); - setOpen((o) => !o); - } - - useDismiss(open, () => setOpen(false), [panelRef, triggerRef]); - // Keep the panel anchored to the trigger as the page resizes/scrolls while open. - useEffect(() => { - if (!open) return; - window.addEventListener("resize", place); - window.addEventListener("scroll", place, true); - return () => { - window.removeEventListener("resize", place); - window.removeEventListener("scroll", place, true); - }; - }, [open]); - function refresh() { qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) }); qc.invalidateQueries({ queryKey: qk.playlists() }); @@ -95,8 +61,7 @@ export default function AddToPlaylist({ } } - async function createAndAdd(e: React.FormEvent) { - e.preventDefault(); + async function createAndAdd() { const name = newName.trim(); if (!name || busy) return; setBusy(true); @@ -117,8 +82,15 @@ export default function AddToPlaylist({ return ( <> - {open && - 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. */} +
e.stopPropagation()}> +
{t("playlists.addToPlaylist")}
e.stopPropagation()} + ref={fade.ref} + style={fade.style} + aria-busy={busy} + className="max-h-56 overflow-y-auto no-scrollbar" > -
{t("playlists.addToPlaylist")}
-
- {lists.length === 0 && !membership.isLoading && ( -
{t("playlists.noneYet")}
- )} - {lists.map((pl) => ( - - ))} -
-
- - setNewName(e.target.value)} - placeholder={t("playlists.newPlaylist")} - className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" - /> - -
, - document.body, - )} + {pl.has_video && } + + {playlistName(pl, t)} + {(pl.source === "youtube" || pl.yt_playlist_id) && ( + + )} + + ))} +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + // Escape bubbles to the popover's dismiss; every other key is text editing here and + // must not reach the surrounding menu's list navigation. + if (e.key === "Escape") return; + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + void createAndAdd(); + } + }} + placeholder={t("playlists.newPlaylist")} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> +
+ +
); } diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx new file mode 100644 index 00000000..d77bd9f2 --- /dev/null +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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 did for TagsCell). Every popover is a node in the app's 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 ). Non-modal so the rest of the page stays interactive. +// Call sites render their options as — 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; + +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 — the hook still works, just without nested-layer awareness. + const nodeId = useFloatingNodeId(); + + // Item registry for list navigation + typeahead, populated by via useListItem. + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const [activeIndex, setActiveIndex] = useState(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 . 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(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, "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) => { + rest.onClick?.(e); + if (!disabled) onSelect?.(); + }; + const handlers = ctx + ? ctx.getItemProps({ ...rest, onClick: mergedClick }) + : { ...rest, onClick: mergedClick }; + + return ( + + ); +} + +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; + /** 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 ; +} + +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 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(() => + 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; + // 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 = ( +
+ {/* 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. */} +
+ {children} +
+
+ ); + + // Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested + // s can self-register and rove. + const positioned = isNav ? ( + + + {surface} + + + ) : ( + surface + ); + + return ( + + + {managed ? ( + + {positioned} + + ) : ( + positioned + )} + + + ); +} diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a8bef922..2b28dfe6 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -22,9 +22,11 @@ import { useFeedFilters } from "./FeedFiltersProvider"; import { useConfirm } from "./ConfirmProvider"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { api, type FeedFilters, type Me } from "../lib/api"; +import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api"; import type { FeedView } from "../lib/feedView"; import { channelYouTubeUrl, formatViews } from "../lib/format"; +import { ChannelTagsControl } from "./ChannelTags"; +import { useChannelTagActions } from "../lib/useChannelTagActions"; // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it @@ -166,6 +168,31 @@ export default function ChannelPage({ onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")), }); + // Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic + // on this channel's detail cache; a failed attach/detach reverts by refetching, and a success + // invalidates the manager list so a tag added here shows there too. + // Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an + // unsubscribed/demo channel page never shows the control, so the request would be thrown away. + const tagsQuery = useQuery({ + queryKey: qk.tags(), + queryFn: api.tags, + enabled: !!ch?.subscribed && !me.is_demo, + }); + const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system); + const { toggleTag, createAndAttachTag } = useChannelTagActions({ + optimisticToggle: (id, tagId, attach) => + qc.setQueryData(qk.channel(id), (old) => + old + ? { + ...old, + tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId), + } + : old, + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }), + onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), + }); + const onUnsubscribe = async () => { const ok = await confirm({ title: t("channel.unsubTitle"), @@ -411,6 +438,19 @@ export default function ChannelPage({ {t("channel.subscribe")} ))} + {/* 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 && ( + 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} + /> + )} diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx new file mode 100644 index 00000000..8abed9f4 --- /dev/null +++ b/frontend/src/components/ChannelTags.tsx @@ -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 ? ( + + ) : ( + {tag.name} + ); +} + +/** 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 ( +
+
+ {userTags.map((tg) => { + const on = attached.has(tg.id); + return ( + 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" + }`} + > + + {on && } + + {tg.name} + + ); + })} + {userTags.length === 0 && ( +

{t("channels.tags.noneYet")}

+ )} +
+
+ 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" + /> + +
+
+ ); +} + +/** 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(null); + const measureRef = useRef(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. +
+ {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */} +
+ {attached.map((tg) => ( + + ))} + +{attached.length} +
+ +
+ {attached.slice(0, visible).map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} + /> + ))} + {hidden > 0 && ( + + )} +
+ + + + {/* Hover: reveal every attached tag when some are collapsed into "+N". */} + + {attached.map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} + /> + ))} + + + {/* Click: the shared editor (toggle existing + create inline). */} + + + +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b8afee54..f5aa60f5 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -12,17 +12,14 @@ import { History, LayoutGrid, Pencil, - Plus, RefreshCw, Table, UserMinus, X, } from "lucide-react"; -import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; -import { useDismiss } from "../lib/useDismiss"; -import { useScrollFade } from "../lib/useScrollFade"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; @@ -30,7 +27,8 @@ import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; -import Overlay from "./Overlay"; +import { ChannelTagsControl } from "./ChannelTags"; +import { useChannelTagActions } from "../lib/useChannelTagActions"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; @@ -198,21 +196,25 @@ export default function Channels() { }, []); // Tagging is updated optimistically in place (no refetch of the whole channel list, which - // made the toggle feel ~2s slow); only revert by refetching if the server call fails. - const toggleTag = (id: string, tagId: number, hasTag: boolean) => { - qc.setQueryData(qk.channels(), (old) => - (old ?? []).map((ch) => - ch.id === id - ? { - ...ch, - tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId], - } - : ch, + // made the toggle feel ~2s slow); only revert by refetching if the server call fails. The + // attach/detach + create-and-attach flow is shared with the channel detail page. + const { toggleTag, createAndAttachTag } = useChannelTagActions({ + optimisticToggle: (id, tagId, attach) => + qc.setQueryData(qk.channels(), (old) => + (old ?? []).map((ch) => + ch.id === id + ? { + ...ch, + tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId), + } + : ch, + ), ), - ); - const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); - call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); - }; + // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags + // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. + onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }), + onError: () => qc.invalidateQueries({ queryKey: qk.channels() }), + }); const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { @@ -453,17 +455,21 @@ export default function Channels() { key: "tags", header: t("channels.cols.tags"), cardLabel: false, + // Bounded width so the cell can't widen the column and reflow the whole table; the control + // fills it and collapses overflow into "+N" (see ChannelTagsControl). + width: "12rem", filter: { kind: "multi", options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), }, render: (c) => ( - toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} - onTagClick={onFilterByTag} + attachedIds={c.tag_ids} + onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(c.id, name)} + onChipClick={onFilterByTag} /> ), }, @@ -989,7 +995,7 @@ function SyncCell({ c }: { c: ManagedChannel }) { ); } return ( -
+
void; - onTagClick: (tagId: number, name: string) => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - // Anchor for the portalled menu. It renders through (i.e. at ) 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(null); - const popRef = useRef(null); - const btnRef = useRef(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 ( -
- {attached.map((tg) => ( - - ))} - - {open && coords && ( - -
- {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} -
-
- )} -
- ); -} - function ActionsCell({ c, isAdmin, diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 6ae3bc70..8f427c68 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,15 +1,16 @@ -import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). // -// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left -// nav's bottom icon cluster — an icon-only button whose menu is portaled to and -// anchored to the right + above the button, escaping the nav's backdrop-filter which would -// otherwise trap an absolutely-positioned popover). +// Two variants, now one implementation on the R8 popover platform: "header" opens below the pill +// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left +// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their +// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses +// the first item when a menu opens, so a hover-open would yank focus off whatever the user was doing. export default function LanguageSwitcher({ value, onChange, @@ -22,128 +23,68 @@ export default function LanguageSwitcher({ variant?: "header" | "rail"; }) { const [open, setOpen] = useState(false); - const closeTimer = useRef | null>(null); - const btnRef = useRef(null); - const panelRef = useRef(null); - const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); - const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0]; - - function openNow() { - if (closeTimer.current) clearTimeout(closeTimer.current); - setOpen(true); - } - function closeSoon() { - closeTimer.current = setTimeout(() => setOpen(false), 200); - } - function toggleRail() { - if (!open) { - const r = btnRef.current?.getBoundingClientRect(); - if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); - } - setOpen((o) => !o); - } - - // Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check - // spans both the button and the floating panel). - useEffect(() => { - if (variant !== "rail" || !open) return; - function onDoc(e: MouseEvent) { - const target = e.target as Node; - if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return; - setOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); - }; - }, [variant, open]); - - const menu = ( -
- {LANGUAGES.map((l) => ( - - ))} -
+ const currentIdx = Math.max( + 0, + LANGUAGES.findIndex((l) => l.code === value), ); + const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0]; + const rail = variant === "rail"; - if (variant === "rail") { - return ( - <> - - {open && - createPortal( -
- {menu} -
, - document.body, - )} - - ); - } - - return ( -
- - {open && ( -
- {LANGUAGES.map((l) => ( - - ))} -
- )} -
+ + {LANGUAGES.map((l) => ( + { + onChange(l.code); + setOpen(false); + }} + label={l.label} + className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg" + > + {l.label} + {l.code === value && } + + ))} + + ); } diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 805743fc..e6738433 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -3,20 +3,15 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { X } from "lucide-react"; import { useBackToClose } from "../lib/history"; +import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack"; +import { useFullscreenElement } from "../lib/fullscreen"; -// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the -// tag editor: pressing ESC dismisses just the error and returns to the editor underneath. -let modalStack: number[] = []; -let nextModalId = 1; - -/** How many s are currently open. The players (which use their own window-level Escape - * listeners, not this stack) check it so their Escape defers to a Modal opened above them — - * otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */ -export function modalCount(): number { - return modalStack.length; -} - -// Small centered modal shell (portaled to ): backdrop + ESC + scroll-lock close. +// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the +// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor +// dismisses just the error and returns to the editor underneath, and a player below defers to it +// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under is painted +// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the +// download dialog) would otherwise open invisibly. export default function Modal({ title, onClose, @@ -31,6 +26,7 @@ export default function Modal({ const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. useBackToClose(onClose); + const fsRoot = useFullscreenElement(); // Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a // drag that begins inside the dialog (e.g. selecting text in an input) and is released out on @@ -41,10 +37,9 @@ export default function Modal({ const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { - const id = nextModalId++; - modalStack.push(id); + const id = pushLayer(); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) { + if (e.key === "Escape" && isTopmost(id)) { e.stopPropagation(); onCloseRef.current(); } @@ -54,7 +49,7 @@ export default function Modal({ document.body.style.overflow = "hidden"; return () => { window.removeEventListener("keydown", onKey); - modalStack = modalStack.filter((x) => x !== id); + removeLayer(id); document.body.style.overflow = prev; }; }, []); @@ -88,6 +83,6 @@ export default function Modal({
{children}
, - document.body, + fsRoot ?? document.body, ); } diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index d0ce865a..d9cac4c0 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useState, useSyncExternalStore } from "react"; import { qk } from "../lib/queryKeys"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; @@ -25,6 +24,7 @@ import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -57,41 +57,20 @@ export default function NavSidebar({ const expanded = pinned || peek.active; const slim = !expanded; // visual: icon-only rail const [acctOpen, setAcctOpen] = useState(false); - const acctBtnRef = useRef(null); - const acctPanelRef = useRef(null); - // Popover position (fixed, viewport coords). It's portaled to so it escapes the - // nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let - // clicks fall through to the controls behind it. - const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); - - function toggleAccount() { - if (!acctOpen) { - const r = acctBtnRef.current?.getBoundingClientRect(); - if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); - } - setAcctOpen((o) => !o); - } - - // Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop - // div, which (sitting between the popover and the page) would break the popover's - // backdrop-filter and make it look solid. - useEffect(() => { - if (!acctOpen) return; - function onDoc(e: MouseEvent) { - const t = e.target as Node; - if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return; - setAcctOpen(false); - } - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") setAcctOpen(false); - } - document.addEventListener("mousedown", onDoc); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDoc); - document.removeEventListener("keydown", onKey); - }; - }, [acctOpen]); + // The account panel holds mixed content with its own tab order (profile, account switches, two + // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose + // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through + // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its + // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now + // comes from the platform instead of a pair of document listeners. + const acctPop = useAnchoredPopover({ + open: acctOpen, + onOpenChange: setAcctOpen, + mode: "dialog", + role: "dialog", + placement: "right-end", + offset: 8, + }); async function logout() { // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then @@ -338,10 +317,10 @@ export default function NavSidebar({ {!slim && {t("header.account.settings")}} -
+
- {acctOpen && - createPortal( -
-
- -
-
{name}
-
{me.email}
-
-
- {me.role === "admin" && ( -
- - {t("header.account.admin")} -
- )} - {otherAccounts.length > 0 && ( -
- {otherAccounts.map((a) => ( - - ))} -
- )} -
- - -
-
, - document.body, + +
+ +
+
{name}
+
{me.email}
+
+
+ {me.role === "admin" && ( +
+ + {t("header.account.admin")} +
)} + {otherAccounts.length > 0 && ( +
+ {otherAccounts.map((a) => ( + + ))} +
+ )} +
+ + +
+
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 23c5fd83..b5cda7fb 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -27,7 +27,8 @@ import { } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; -import { modalCount } from "./Modal"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; +import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack"; import DownloadButton from "./DownloadButton"; import { api, type Video, type VideoStatus } from "../lib/api"; import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube"; @@ -67,6 +68,108 @@ type LoopMode = "off" | "one" | "all"; const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"]; const LOOP_MODES: LoopMode[] = ["off", "one", "all"]; +/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. + * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass + * theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark, + * labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one, + * so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */ +function PlayerTransportControls({ + variant, + index, + queueLength, + autoMode, + loopMode, + onPrev, + onNext, + onCycleAuto, + onCycleLoop, +}: { + variant: "docked" | "modal"; + index: number; + queueLength: number; + autoMode: AutoMode; + loopMode: LoopMode; + onPrev: () => void; + onNext: () => void; + onCycleAuto: () => void; + onCycleLoop: () => void; +}) { + const { t } = useTranslation(); + const docked = variant === "docked"; + const navBtn = docked + ? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition" + : "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"; + const navLabel = docked ? "hidden sm:inline" : ""; + const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums"; + const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border"; + const toggle = (on: boolean) => + docked + ? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${ + on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white" + }` + : `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${ + on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg" + }`; + const toggleLabel = docked ? "hidden md:inline" : ""; + return ( + <> + + + {index + 1} / {queueLength} + + + + {/* Persistent playback settings — cycle on click, saved to your account. */} + + + + ); +} + /** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)? * Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut * must not eat the only keyboard way to read a long description. Stops at ``: the page @@ -446,41 +549,21 @@ export default function PlayerModal({ } }; - // Lazy description (fetched only when the title is hovered). The popover is - // portaled to with fixed positioning so the modal card's overflow-y-auto - // can't clip it. A small close grace lets the mouse travel title → popover. + // Lazy description (fetched only when the title is hovered). Anchored ABOVE the title so it grows + // upward over the player rather than downward off-screen / behind the OS taskbar — `flip` still + // rescues it when there is no room up there either, which the old fixed `bottom` could not. The + // hover-intent delay (only pop it if the pointer lingers, so a quick pass doesn't flash the card) + // and the travel grace are now the platform's, along with the safePolygon bridge that replaces the + // pair of hand-managed timers. const [showDesc, setShowDesc] = useState(false); - // `bottom` anchors the popover just above the title so it grows upward (over the - // player) instead of downward off-screen / behind the OS taskbar. - const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>( - null, - ); - const titleRef = useRef(null); - const closeTimer = useRef(undefined); - const openTimer = useRef(undefined); - const openDescNow = () => { - window.clearTimeout(closeTimer.current); - window.clearTimeout(openTimer.current); - const el = titleRef.current; - if (el) { - const r = el.getBoundingClientRect(); - const width = Math.min(560, window.innerWidth - 32); - const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16)); - setDescRect({ left, bottom: window.innerHeight - r.top + 8, width }); - } - setShowDesc(true); - }; - // Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a - // quick pass over it doesn't flash the popover. - const scheduleOpenDesc = () => { - window.clearTimeout(closeTimer.current); - window.clearTimeout(openTimer.current); - openTimer.current = window.setTimeout(openDescNow, 400); - }; - const scheduleCloseDesc = () => { - window.clearTimeout(openTimer.current); - closeTimer.current = window.setTimeout(() => setShowDesc(false), 150); - }; + const descPop = useAnchoredPopover({ + open: showDesc, + onOpenChange: setShowDesc, + mode: "hover", + placement: "top-start", + offset: 8, + hoverDelay: { open: 400, close: 150 }, + }); const detail = useQuery({ queryKey: qk.videoDetail(currentVideoId), queryFn: () => api.videoDetail(currentVideoId), @@ -490,12 +573,28 @@ export default function PlayerModal({ staleTime: 5 * 60_000, }); + // This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the + // download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers. + const layerIdRef = useRef(null); + useEffect(() => { + const id = pushLayer(); + layerIdRef.current = id; + return () => { + removeLayer(id); + layerIdRef.current = null; + }; + }, []); + // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock. // These fire only while focus is on our page, not inside the cross-origin player iframe — so // we focus the modal card on open and again on player-ready (autoplay can grab focus). useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + // Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an + // anchored popover): it takes the Escape and we do nothing — so we don't also step out of + // maximise or close the player underneath it. + if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return; // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal. if (fullscreenElement()) return; // Same for our own maximise: Esc steps back out of it before it closes the player. @@ -504,9 +603,6 @@ export default function PlayerModal({ setMaximized(false); return; } - // Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes - // it, ours would otherwise also fire and close the player underneath it (U-C). - if (modalCount() > 0) return; onClose(); return; } @@ -560,8 +656,6 @@ export default function PlayerModal({ return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; - window.clearTimeout(closeTimer.current); - window.clearTimeout(openTimer.current); window.clearTimeout(volTimerRef.current); // Flush a debounced volume write — closing the player right after a wheel spin (the normal // way to leave) must not drop the level the user just set. @@ -875,8 +969,9 @@ export default function PlayerModal({ bar — that stays reachable by hovering the video's bottom edge, just above ours. Always visible (no auto-hide) so there's no guessing whether it will appear and no fight with the seek bar over the same pixels. stopPropagation so a click here doesn't toggle playback; - the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true - fullscreen — their popovers portal to , outside the fullscreen element. */} + the exit button also re-arms our shortcuts. Add-to-playlist works in true fullscreen (its + popover portals INTO the fullscreen element); download stays hidden there because it opens + a Modal, which still portals to — outside the fullscreen element. */} {(maximized || isFullscreen) && (
e.stopPropagation()} @@ -884,76 +979,30 @@ export default function PlayerModal({ > {hasQueue && ( <> - - - {index + 1} / {queue!.length} - - - - - + )} - {!navigated && !isFullscreen && ( + {/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both + portal into the fullscreen element rather than (a body-portaled node is + painted underneath the element holding the lock, i.e. invisible). */} + {!navigated && ( )} - {!navigated && !isFullscreen && ( + {!navigated && ( - - - {index + 1} / {queue!.length} - - - - {/* Persistent playback settings — cycle on click, saved to your account. */} - - +
)} @@ -1115,10 +1120,9 @@ export default function PlayerModal({

{/* Hover target is the text itself (inline), not the whole row. */} {navigated ? (liveData?.title ?? t("player.loading")) : active.title} @@ -1133,43 +1137,32 @@ export default function PlayerModal({ {t("player.back")} )} - {showDesc && - descRect && - createPortal( + +
+ {t("player.description")} +
+ {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? (
-
- {t("player.description")} -
- {detail.isLoading ? ( -
{t("player.loading")}
- ) : detail.data?.description ? ( -
- {renderDescription(detail.data.description, { - currentId: currentVideoId, - onSeek: seekTo, - onLoadVideo: loadVideo, - t, - })} -
- ) : ( -
{t("player.noDescription")}
- )} -
, - document.body, + {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} + + ) : ( +
{t("player.noDescription")}
)} +
- {open && - createPortal( -
-
- - - {formatViews(data.my_videos)}{" "} - {t("header.sync.yours")} - / - {formatViews(data.total_videos)} {t("header.sync.total")} - -
- {showMain && ( -
- {stateNode} - {pauseBtn} -
- )} - {notFull > 0 && ( -
- - {!showMain && pauseBtn} -
- )} -
, - document.body, + +
+ + + {formatViews(data.my_videos)}{" "} + {t("header.sync.yours")} + / + {formatViews(data.total_videos)} {t("header.sync.total")} + +
+ {showMain && ( +
+ {stateNode} + {pauseBtn} +
)} + {notFull > 0 && ( +
+ + {!showMain && pauseBtn} +
+ )} +
); } diff --git a/frontend/src/components/TagManager.tsx b/frontend/src/components/TagManager.tsx index 0fe3dc0d..9ea80199 100644 --- a/frontend/src/components/TagManager.tsx +++ b/frontend/src/components/TagManager.tsx @@ -1,5 +1,4 @@ -import { useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; @@ -9,10 +8,12 @@ import { notify } from "../lib/notifications"; import { useScrollFade } from "../lib/useScrollFade"; import Modal from "./Modal"; import { useConfirm } from "./ConfirmProvider"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // hover popover on the count listing the tagged channels (each a link that focuses it in the -// Channel manager). The popover is portaled to so the list's own scroll can't clip it. +// Channel manager). The popover rides the R8 platform, so the list's own scroll can't clip it and +// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer. function TagRow({ tag, channels, @@ -29,25 +30,20 @@ function TagRow({ const { t } = useTranslation(); const [name, setName] = useState(tag.name); const [open, setOpen] = useState(false); - const [pos, setPos] = useState({ left: 0, top: 0 }); const popFade = useScrollFade(); - const countRef = useRef(null); - const closeTimer = useRef(undefined); const commit = () => { const v = name.trim(); if (v && v !== tag.name) onRename(v); else setName(tag.name); }; - const openPop = () => { - window.clearTimeout(closeTimer.current); - if (channels.length === 0) return; - const r = countRef.current?.getBoundingClientRect(); - if (r) setPos({ left: r.right + 8, top: r.top }); - setOpen(true); - }; - const closeSoon = () => { - closeTimer.current = window.setTimeout(() => setOpen(false), 150); - }; + const pop = useAnchoredPopover({ + open: open && channels.length > 0, + onOpenChange: setOpen, + mode: "hover", + placement: "right-start", + offset: 8, + hoverDelay: { open: 0, close: 150 }, + }); return (
- {open && - createPortal( -
-
- {t("tagManager.onChannels")} -
- {channels.map((ch) => ( - - ))} -
, - document.body, - )} + +
+ {t("tagManager.onChannels")} +
+ {/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */} +
+ {channels.map((ch) => ( + + ))} +
+
); } diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 7c2d94eb..2e6831d8 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,16 +1,13 @@ -import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; -import { createPortal } from "react-dom"; +import { useState, useSyncExternalStore } from "react"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { hintsEnabled, subscribeHints } from "../lib/hints"; type Side = "top" | "bottom"; -type Coords = { left: number; top: number; placement: Side }; - -const MARGIN = 8; -const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get /** Wrap any element to show a short glass hint caption on hover — but only while the - * app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with - * fixed positioning so it is never clipped by an overflow/stacking ancestor. */ + * app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it + * is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room + * and slides back inside the viewport near an edge. */ export default function Tooltip({ hint, side = "top", @@ -21,74 +18,34 @@ export default function Tooltip({ children: React.ReactNode; }) { const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); - const ref = useRef(null); - const tipRef = useRef(null); - const [coords, setCoords] = useState(null); - - function show() { - const el = ref.current; - if (!el) return; - const r = el.getBoundingClientRect(); - // Prefer the requested side; flip to bottom if there's no room above. - const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top"; - const center = r.left + r.width / 2; - // Clamp using the MAX half-width so the caption can never overflow the viewport, even on the - // first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push - // the centered box off-screen. The layout effect below refines this to the actual width. - setCoords({ - left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN), - top: placement === "top" ? r.top - 8 : r.bottom + 8, - placement, - }); - } - function hide() { - setCoords(null); - } - - // Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't - // need the full 240px reservation), still clamped inside the viewport. - useLayoutEffect(() => { - const tip = tipRef.current; - const el = ref.current; - if (!coords || !tip || !el) return; - const r = el.getBoundingClientRect(); - const half = tip.offsetWidth / 2; - const left = Math.min( - Math.max(r.left + r.width / 2, half + MARGIN), - window.innerWidth - half - MARGIN, - ); - if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c)); - }, [coords]); + const [open, setOpen] = useState(false); + const pop = useAnchoredPopover({ + open: open && enabled && !!hint, + onOpenChange: setOpen, + mode: "hover", + placement: side, + offset: 8, + // Show/hide on contact, as the pre-platform tooltip did — the platform's 120/140 ms hover-intent + // default suits interactive cards but makes a fast sweep across an icon row surface no hints. + hoverDelay: { open: 0, close: 0 }, + }); if (!enabled || !hint) return <>{children}; return ( - - {children} - {coords && - createPortal( -
- {hint} -
, - document.body, - )} -
+ <> + + {children} + + {/* Passive caption: it must not swallow a click aimed at a neighbouring control that it + overlaps while the hover-close delay runs out. */} + + {hint} + + ); } diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx index 80a808e5..61d38d98 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ViewSwitcher.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { Check, ChevronDown, type LucideIcon } from "lucide-react"; import clsx from "clsx"; -import { useDismiss } from "../lib/useDismiss"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; export interface ViewOption { id: T; @@ -13,9 +13,11 @@ export interface ViewOption { // menu behind it. Generic over the mode id so any module's list can adopt it (the feed's // density modes; the managers' layouts) without re-deriving the menu behaviour. // -// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End -// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the -// menu unmounts under the focused item, which would otherwise strand focus on ). +// This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine. +// It now rides the R8 popover platform like every other menu, which supplies the same model +// (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the +// two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead +// of being absolutely positioned inside whatever ancestor happens to clip it. export default function ViewSwitcher({ value, options, @@ -34,19 +36,6 @@ export default function ViewSwitcher({ labelClassName?: string; }) { const [open, setOpen] = useState(false); - // Which item holds the roving tabindex; seeded to the active one when the menu opens. - const [focusIdx, setFocusIdx] = useState(0); - const btnRef = useRef(null); - const menuRef = useRef(null); - const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); - - function close({ refocus = true }: { refocus?: boolean } = {}) { - setOpen(false); - if (refocus) btnRef.current?.focus(); - } - // Outside-click must NOT refocus the trigger — that would steal focus from whatever the user - // just clicked. Escape is handled on the menu itself (below), where returning focus is right. - useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]); // A `value` outside `options` is a caller bug; degrade to the first option rather than render // nothing. The menu then shows no checked item, which is honest — nothing IS selected. @@ -54,55 +43,24 @@ export default function ViewSwitcher({ 0, options.findIndex((o) => o.id === value), ); - // Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect - // would only schedule the update, so the focus effect below would still see the previous index - // and focus the wrong item for one render before correcting. - function openMenu() { - setFocusIdx(currentIdx); - setOpen(true); - } - // Clamped at render, so a caller whose option list shrinks while the menu is open can't leave - // the roving tabindex pointing past the end (which would make every item un-tabbable). - const rovingIdx = Math.min(focusIdx, options.length - 1); - useEffect(() => { - if (open) itemRefs.current[rovingIdx]?.focus(); - }, [open, rovingIdx]); + + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + placement: "bottom-end", + // Opening a "pick one of these modes" menu should land on the mode that is already picked. + selectedIndex: currentIdx, + }); const current = options[currentIdx]; if (!current) return null; const CurrentIcon = current.icon; - function onMenuKeyDown(e: React.KeyboardEvent) { - const last = options.length - 1; - if (e.key === "Escape") { - // Stop it reaching useDismiss's document listener, which would close without refocusing. - e.stopPropagation(); - close(); - } else if (e.key === "ArrowDown") { - e.preventDefault(); - setFocusIdx((i) => (i >= last ? 0 : i + 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setFocusIdx((i) => (i <= 0 ? last : i - 1)); - } else if (e.key === "Home") { - e.preventDefault(); - setFocusIdx(0); - } else if (e.key === "End") { - e.preventDefault(); - setFocusIdx(last); - } else if (e.key === "Tab") { - // Tabbing out of a menu closes it, but the focus is moving on by itself. - close({ refocus: false }); - } - } - return ( -
+ <> - {open && ( -
- {/* The modes are one exclusive choice, so the radio items need a group to scope their - checked-ness to — otherwise they read as independent checkable items. */} -
- {options.map((o, i) => { - const Icon = o.icon; - const on = o.id === value; - return ( - - ); - })} -
+ + {/* The modes are one exclusive choice, so the radio items need a group to scope their + checked-ness to — otherwise they read as independent checkable items. */} +
+ {options.map((o) => { + const Icon = o.icon; + const on = o.id === value; + return ( + { + onChange(o.id); + setOpen(false); + }} + label={o.label} + className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${ + on ? "text-accent" : "text-fg hover:bg-card" + }`} + > + + {o.label} + {on && } + + ); + })}
- )} -
+ + ); } diff --git a/frontend/src/components/tableHeader.tsx b/frontend/src/components/tableHeader.tsx index 3b92bcd5..55507138 100644 --- a/frontend/src/components/tableHeader.tsx +++ b/frontend/src/components/tableHeader.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; -import { useDismiss } from "../lib/useDismiss"; +import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { useScrollFade } from "../lib/useScrollFade"; import { type SortState } from "../lib/columnSort"; @@ -85,100 +85,145 @@ function FilterOptionList({ children }: { children: ReactNode }) { ); } -// MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW -// component type on every render: React unmounted and remounted the popover on each keystroke, so -// the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The +// MODULE LEVEL, deliberately. The panel used to be declared inside DataTable's body, which made it a +// NEW component type on every render: React unmounted and remounted the popover on each keystroke, +// so the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The // `autoFocus` masked it well enough that it read as "the filter is just twitchy". -function FilterPopover({ +// +// 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 , and the useDismiss +// registration that had to include the trigger so its own mousedown didn't close-then-reopen. +function FilterFunnel({ col, value, onApply, - panelRef, + open, + onOpenChange, }: { col: Column; value: string | string[] | undefined; onApply: (key: string, value: string | string[]) => void; - panelRef: RefObject; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation(); const f = col.filter!; + const inputRef = useRef(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 ( -
- {f.kind === "text" && ( - onApply(col.key, e.target.value)} - placeholder={col.header} - className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" - /> - )} - {f.kind === "select" && ( -
- - {f.options.map((o) => ( - + + {f.kind === "text" && ( + onApply(col.key, e.target.value)} + placeholder={col.header} + className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + )} + {f.kind === "select" && ( +
+ 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} - - ))} -
- )} - {f.kind === "multi" && ( - - {f.options.length === 0 && ( - {t("datatable.noOptions")} - )} - {f.options.map((o) => { - const arr = (value as string[]) ?? []; - const on = arr.includes(o.value); - return ( -
+ )} + {f.kind === "multi" && ( + + {f.options.length === 0 && ( + {t("datatable.noOptions")} + )} + {f.options.map((o) => { + const arr = (value as string[]) ?? []; + const on = arr.includes(o.value); + return ( + + 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 && } - - {o.label} - - ); - })} - - )} - {isActive(value) && ( - - )} -
+ + {on && } + + {o.label} + + ); + })} + + )} + {filterOn && ( + + )} + + ); } @@ -203,33 +248,14 @@ export function ColumnHead({ filters: FilterMap; onFilter: (key: string, value: string | string[]) => void; }) { - const { t } = useTranslation(); + // Only one column's filter panel is open at a time; each funnel owns its own anchoring. const [openFilter, setOpenFilter] = useState(null); - const panelRef = useRef(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(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 , - // 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 ( {columns.map((col) => { const isSorted = sort?.key === col.key; - const filterOn = isActive(filters[col.key]); return ( ({ {col.header} )} {col.filter && ( - + setOpenFilter(o ? col.key : null)} + /> )} - {openFilter === col.key && col.filter && ( - - )} ); })} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index e9249120..7ec4d8ca 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -46,7 +46,11 @@ "tags": { "yourTags": "Your tags", "manage": "Manage tags", - "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)" + "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", + "noneYet": "No tags yet — create one below.", + "create": "Create tag", + "createPlaceholder": "New tag…", + "createFailed": "Could not create the tag." }, "loading": "Loading channels…", "empty": "No channels.", @@ -103,8 +107,8 @@ "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.", "resetBackfill": "Reset & re-fetch this channel", "resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.", - "fullHistoryQueued": "full history queued", - "fullHistoryComing": "full history coming", + "fullHistoryQueued": "queued", + "fullHistoryComing": "coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", "hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 55e3a113..e61d737c 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -46,7 +46,11 @@ "tags": { "yourTags": "Címkéid", "manage": "Címkék kezelése", - "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)" + "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", + "noneYet": "Még nincs címke — hozz létre egyet lent.", + "create": "Címke létrehozása", + "createPlaceholder": "Új címke…", + "createFailed": "A címke létrehozása nem sikerült." }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", @@ -103,8 +107,8 @@ "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.", "resetBackfill": "Csatorna resetelése és újraletöltése", "resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", - "fullHistoryQueued": "teljes előzmény sorban", - "fullHistoryComing": "teljes előzmény úton", + "fullHistoryQueued": "sorban", + "fullHistoryComing": "úton", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.", diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index 0e49fe8a..1ed889b2 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -31,6 +31,7 @@ export interface ChannelDetail { backfill_done: boolean; from_explore: boolean; details_synced: boolean; + tag_ids: number[]; } export interface ExploreResult { diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index f9eb575d..afb17edd 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -12,7 +12,7 @@ export interface Tag { export const tagsApi = { tags: (): Promise => req("/api/tags"), - createTag: (t: { name: string; color?: string; category?: string }) => + createTag: (t: { name: string; color?: string; category?: string }): Promise => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), updateTag: (id: number, patch: { name?: string; color?: string }) => req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts index 9dae2cfc..7b907665 100644 --- a/frontend/src/lib/fullscreen.ts +++ b/frontend/src/lib/fullscreen.ts @@ -1,3 +1,5 @@ +import { useSyncExternalStore } from "react"; + // The browser's Fullscreen API, WebKit included. // // Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no @@ -47,3 +49,16 @@ export function onFullscreenChange(handler: () => void): () => void { document.removeEventListener("webkitfullscreenchange", handler); }; } + +// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across +// calls — a valid useSyncExternalStore snapshot without any caching of our own. +const fsSnapshot = () => fullscreenElement() as HTMLElement | null; +const fsServerSnapshot = () => null; + +/** The fullscreen element as reactive state, for code that must RENDER differently while something + * holds the lock — chiefly the popover platform, which has to portal INTO that element (a node + * portaled to 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); +} diff --git a/frontend/src/lib/layerStack.ts b/frontend/src/lib/layerStack.ts new file mode 100644 index 00000000..d0380824 --- /dev/null +++ b/frontend/src/lib/layerStack.ts @@ -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 ; 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(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), + }; +} diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index 08e7f4a9..838e5c06 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -134,6 +134,12 @@ export function DetailCustomizeMenu({ const btnRef = useRef(null); const menuRef = useRef(null); useDismiss(open, () => setOpen(false), [menuRef, btnRef]); + // Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders + // inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel + // out to — 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 (