Compare commits
96
Commits
dev
..
2a70d145e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a70d145e9 | ||
|
|
bca67a3547 | ||
|
|
2c7c8e0f8a | ||
|
|
5e2195eb5a | ||
|
|
53b599271d | ||
|
|
ab1272beb4 | ||
|
|
8ee46aac09 | ||
|
|
edb0f8a4e1 | ||
|
|
3a982b8be8 | ||
|
|
9556b33648 | ||
|
|
092df4f7eb | ||
|
|
01ea9ab8e1 | ||
|
|
9089096d1e | ||
|
|
e34cd7b04f | ||
|
|
47d955b828 | ||
|
|
486a5cd1b0 | ||
|
|
ca9fd2a5ce | ||
|
|
8501509acb | ||
|
|
9f26ab3c80 | ||
|
|
29b8dd4629 | ||
|
|
9adb403ca8 | ||
|
|
759fb41f78 | ||
|
|
d6d6a0f717 | ||
|
|
a3b62a2535 | ||
|
|
2834056025 | ||
|
|
2840d1b532 | ||
|
|
70f419edf3 | ||
|
|
2fef26cd75 | ||
|
|
4353327e30 | ||
|
|
7842e48952 | ||
|
|
1401294ad1 | ||
|
|
409f8c0ce8 | ||
|
|
c3987a8d52 | ||
|
|
92bba3abf2 | ||
|
|
1d908cabb2 | ||
|
|
033b1d454e | ||
|
|
f863181778 | ||
|
|
91eb8d464a | ||
|
|
ecdbde04e7 | ||
|
|
26ae20ec0c | ||
|
|
bb4e9f5e57 | ||
|
|
6bdc872c1c | ||
|
|
0c761533e7 | ||
|
|
4fbabefb81 | ||
|
|
0256fddfb8 | ||
|
|
938cf2d8da | ||
|
|
ded573ff16 | ||
|
|
dfc858759e | ||
|
|
fb6a1d3ba9 | ||
|
|
d5dccf4c92 | ||
|
|
bc9b208de1 | ||
|
|
b262669cea | ||
|
|
0283773456 | ||
|
|
1741607ebf | ||
|
|
e3d26ef671 | ||
|
|
3287aa1208 | ||
|
|
b75f07a2bb | ||
|
|
e1aa0e29c3 | ||
|
|
c5fd4dfa0f | ||
|
|
a8e498d730 | ||
|
|
fde5066fd8 | ||
|
|
0e552e452d | ||
|
|
1b91d7673d | ||
|
|
e2ad2ab798 | ||
|
|
870fff2196 | ||
|
|
a13d24d249 | ||
|
|
7b18fdc47a | ||
|
|
d3d1760f15 | ||
|
|
3d2755d138 | ||
|
|
a146a353f7 | ||
|
|
e1f8bf0bd8 | ||
|
|
b4e50d348e | ||
|
|
bc81ac5d08 | ||
|
|
0831ae78fe | ||
|
|
43e2805ec3 | ||
|
|
c1ff1bad1b | ||
|
|
fed1446b90 | ||
|
|
d950bfd477 | ||
|
|
4e990efab0 | ||
|
|
139e0d067d | ||
|
|
022bab3518 | ||
|
|
7c0655a8f5 | ||
|
|
db6b108a6f | ||
|
|
657cdfa507 | ||
|
|
a1fd6fce91 | ||
|
|
19eb0242f8 | ||
|
|
0edd7af45d | ||
|
|
c2bf5b025c | ||
|
|
1b2e0741c6 | ||
|
|
659fb5b228 | ||
|
|
831dee52bc | ||
|
|
6d2042d8ae | ||
|
|
079c8182b4 | ||
|
|
424e820276 | ||
|
|
ff1d36238f | ||
|
|
bd2e39d6f8 |
@@ -230,7 +230,6 @@ def _channel_detail_dict(
|
||||
blocked: bool,
|
||||
stored: int,
|
||||
deep_requested: bool,
|
||||
tag_ids: list[int],
|
||||
) -> dict:
|
||||
return {
|
||||
"id": channel.id,
|
||||
@@ -258,9 +257,6 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -301,13 +297,6 @@ 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,
|
||||
@@ -315,7 +304,6 @@ def channel_detail(
|
||||
blocked=blocked,
|
||||
stored=int(stored),
|
||||
deep_requested=bool(sub.deep_requested) if sub else False,
|
||||
tag_ids=tag_ids,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""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]
|
||||
Generated
-60
@@ -9,7 +9,6 @@
|
||||
"@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",
|
||||
@@ -996,59 +995,6 @@
|
||||
"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",
|
||||
@@ -5348,12 +5294,6 @@
|
||||
"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",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
const MARGIN = 8;
|
||||
const POP_W = 480; // the overlay's fixed width (px)
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
* (the same content as the detail page's About tab — description, links, country, language, topics,
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
|
||||
const preview = (text ?? "").trim();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const 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 [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
const { data: ch } = useQuery({
|
||||
queryKey: qk.channel(channelId),
|
||||
@@ -37,29 +31,105 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
function place() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setCoords({
|
||||
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
|
||||
top: r.bottom + 6,
|
||||
});
|
||||
}
|
||||
function show() {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
place();
|
||||
setOpen(true);
|
||||
}
|
||||
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
|
||||
// vanishing — the overlay's own mouseenter cancels it.
|
||||
function scheduleHide() {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
hideTimer.current = setTimeout(() => setOpen(false), 160);
|
||||
}
|
||||
function cancelHide() {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
|
||||
// when the scroll happens inside the overlay itself (it is scrollable).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Flip above the anchor if the overlay would run off the bottom of the viewport.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const el = ref.current;
|
||||
if (!open || !coords || !pop || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const h = pop.offsetHeight;
|
||||
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
|
||||
const top = r.top - 6 - h;
|
||||
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
|
||||
}
|
||||
}, [open, coords, ch]);
|
||||
|
||||
if (!preview) return <span className="text-muted">—</span>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={scheduleHide}
|
||||
onFocus={show}
|
||||
onBlur={scheduleHide}
|
||||
tabIndex={0}
|
||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||
>
|
||||
{preview}
|
||||
{open &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={scheduleHide}
|
||||
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
||||
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
>
|
||||
{/* Full About once the detail loads; until then the list's truncated description. */}
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 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.
|
||||
// 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.
|
||||
export default function AddToPlaylist({
|
||||
videoId,
|
||||
className,
|
||||
@@ -22,25 +21,60 @@ export default function AddToPlaylist({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fade = useScrollFade();
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
offset: 6,
|
||||
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
||||
// roving list so they don't steal the caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const membership = useQuery({
|
||||
queryKey: qk.playlistMembership(videoId),
|
||||
queryFn: () => api.playlists(videoId),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
// Re-place once the panel is actually rendered (so we know its real height) and whenever
|
||||
// its content — hence height — changes, so the flip-above decision uses the true size.
|
||||
useLayoutEffect(() => {
|
||||
if (open) place();
|
||||
}, [open, membership.data]);
|
||||
|
||||
function place() {
|
||||
const r = triggerRef.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const width = 240;
|
||||
const margin = 8;
|
||||
// Use the panel's real height once it's mounted; estimate on the first open.
|
||||
const h = panelRef.current?.offsetHeight ?? 300;
|
||||
let top = r.bottom + 6;
|
||||
if (top + h > window.innerHeight - margin) {
|
||||
// Not enough room below — open upward, clamped to the viewport.
|
||||
top = Math.max(margin, r.top - h - 6);
|
||||
}
|
||||
const left = Math.min(Math.max(margin, r.left), window.innerWidth - width - margin);
|
||||
setCoords({ top: Math.round(top), left: Math.round(left) });
|
||||
}
|
||||
|
||||
function toggleOpen(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!open) place();
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
useDismiss(open, () => setOpen(false), [panelRef, triggerRef]);
|
||||
// Keep the panel anchored to the trigger as the page resizes/scrolls while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", place);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function refresh() {
|
||||
qc.invalidateQueries({ queryKey: qk.playlistMembership(videoId) });
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
@@ -61,7 +95,8 @@ export default function AddToPlaylist({
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndAdd() {
|
||||
async function createAndAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name || busy) return;
|
||||
setBusy(true);
|
||||
@@ -82,15 +117,8 @@ export default function AddToPlaylist({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps({
|
||||
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
||||
// reach it.
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
})}
|
||||
ref={triggerRef}
|
||||
onClick={toggleOpen}
|
||||
title={t("playlists.addToPlaylist")}
|
||||
aria-label={t("playlists.addToPlaylist")}
|
||||
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
||||
@@ -98,69 +126,59 @@ export default function AddToPlaylist({
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2 shadow-2xl">
|
||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
aria-busy={busy}
|
||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
||||
ref={panelRef}
|
||||
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
|
||||
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{lists.length === 0 && !membership.isLoading && (
|
||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{lists.map((pl) => (
|
||||
<PopoverItem
|
||||
key={pl.id}
|
||||
// A video can sit in several playlists at once — checkboxes, not one choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={pl.has_video}
|
||||
onSelect={() => toggle(pl)}
|
||||
// NOT `disabled={busy}`: the native disabled attribute would blur the focused item to
|
||||
// <body> mid-request and permanently kill roving focus (useListNavigation listens on
|
||||
// the floating element only). `toggle()` already no-ops while busy, so re-entrancy is
|
||||
// guarded without touching focus; `aria-busy` on the list conveys the pending state.
|
||||
label={playlistName(pl, t)}
|
||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-none transition hover:bg-card/60 data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)]"
|
||||
>
|
||||
<span
|
||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className="max-h-56 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{lists.length === 0 && !membership.isLoading && (
|
||||
<div className="text-xs text-muted px-1.5 py-2">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{lists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
onClick={() => toggle(pl)}
|
||||
disabled={busy}
|
||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg hover:bg-card/60 transition disabled:opacity-50"
|
||||
>
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
)}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape bubbles to the popover's dismiss; every other key is text editing here and
|
||||
// must not reach the surrounding menu's list navigation.
|
||||
if (e.key === "Escape") return;
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void createAndAdd();
|
||||
}
|
||||
}}
|
||||
placeholder={t("playlists.newPlaylist")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
<span
|
||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={createAndAdd}
|
||||
className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("playlists.newPlaylist")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset as offsetMiddleware,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
hide,
|
||||
useClick,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useListNavigation,
|
||||
useTypeahead,
|
||||
useInteractions,
|
||||
useFloatingNodeId,
|
||||
useListItem,
|
||||
safePolygon,
|
||||
FloatingNode,
|
||||
FloatingList,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { useFullscreenElement } from "../lib/fullscreen";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
|
||||
// - flip() → measured two-way flip (top/bottom) when a side runs out of room
|
||||
// - shift() → slides back into view horizontally (the old MEASURED-width clamp)
|
||||
// - hide() → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we
|
||||
// hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back)
|
||||
// - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
|
||||
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
|
||||
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
|
||||
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
|
||||
//
|
||||
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
|
||||
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
|
||||
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
|
||||
// - useTypeahead → type-to-focus a matching item by its label
|
||||
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
|
||||
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
|
||||
// Escape strands focus on <body>). Non-modal so the rest of the page stays interactive.
|
||||
// Call sites render their options as <PopoverItem> — it self-registers (useListItem) and carries the
|
||||
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
|
||||
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
|
||||
// typeahead act on the text, not the list — see ChannelTags.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
offset?: number;
|
||||
/** Hover mode only: open/close intent delays, ms. */
|
||||
hoverDelay?: number | { open?: number; close?: number };
|
||||
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
|
||||
role?: UseRoleProps["role"];
|
||||
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
|
||||
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
|
||||
fitHeight?: boolean;
|
||||
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
|
||||
* contain a free-text input, where character keys belong to the text field. */
|
||||
typeahead?: boolean;
|
||||
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: boolean;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
}
|
||||
|
||||
// Internal: call sites use the inferred hook return, they don't name the type.
|
||||
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
|
||||
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
|
||||
const isNav = mode === "menu" || mode === "listbox";
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
|
||||
const elementsRef = useRef<Array<HTMLElement | null>>([]);
|
||||
const labelsRef = useRef<Array<string | null>>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
|
||||
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
|
||||
nodeId,
|
||||
open: opts.open,
|
||||
onOpenChange: opts.onOpenChange,
|
||||
placement: opts.placement ?? "bottom-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offsetMiddleware(opts.offset ?? 4),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
...(opts.fitHeight
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
hide({ padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
// A stale active item would highlight (and steal the next open's initial focus) the second time a
|
||||
// menu opens; clear it whenever the surface closes.
|
||||
useEffect(() => {
|
||||
if (!opts.open) setActiveIndex(null);
|
||||
}, [opts.open]);
|
||||
|
||||
const click = useClick(context, { enabled: !isHover });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
move: false,
|
||||
delay: opts.hoverDelay ?? { open: 120, close: 140 },
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
||||
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: isNav,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
onNavigate: setActiveIndex,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
loop: true,
|
||||
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
|
||||
// which strands focus on the menu container — arrows then can't enter the roving list at all).
|
||||
focusItemOnOpen: true,
|
||||
// A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely
|
||||
// passes over it (the library default) — that blurs the input mid-typing and mis-routes the next
|
||||
// Enter to the hovered item. Turned off together with typeahead for such menus.
|
||||
focusItemOnHover: !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
listRef: labelsRef,
|
||||
activeIndex,
|
||||
onMatch: setActiveIndex,
|
||||
});
|
||||
|
||||
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
|
||||
|
||||
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
|
||||
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
|
||||
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
|
||||
|
||||
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
isPositioned,
|
||||
referenceHidden,
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
activeIndex,
|
||||
...interactions,
|
||||
};
|
||||
}
|
||||
|
||||
// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
|
||||
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
|
||||
interface PopoverListContextValue {
|
||||
activeIndex: number | null;
|
||||
getItemProps: AnchoredPopover["getItemProps"];
|
||||
}
|
||||
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
|
||||
|
||||
type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option";
|
||||
|
||||
/** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving
|
||||
* focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are
|
||||
* one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once,
|
||||
* `option`+`selected` for a listbox choice, plain `menuitem` for an action.
|
||||
*
|
||||
* The active (roving) item carries `data-active` — style the highlight off THAT, not `:focus-visible`:
|
||||
* a click-opened menu focuses the first item programmatically, which does NOT reliably trip the
|
||||
* focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow
|
||||
* key. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the
|
||||
* theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix
|
||||
* arbitrary value like the app's own glass idiom:
|
||||
* `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */
|
||||
export function PopoverItem({
|
||||
children,
|
||||
onSelect,
|
||||
role = "menuitem",
|
||||
checked,
|
||||
selected,
|
||||
disabled,
|
||||
className,
|
||||
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
|
||||
label,
|
||||
...rest
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect?: () => void;
|
||||
role?: ItemRole;
|
||||
checked?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
|
||||
const ctx = useContext(PopoverListContext);
|
||||
const textLabel = label ?? (typeof children === "string" ? children : undefined);
|
||||
const { ref, index } = useListItem({ label: textLabel });
|
||||
const active = ctx?.activeIndex === index;
|
||||
|
||||
const ariaProps =
|
||||
role === "menuitemradio" || role === "menuitemcheckbox"
|
||||
? { role, "aria-checked": !!checked }
|
||||
: role === "option"
|
||||
? { role, "aria-selected": !!selected }
|
||||
: { role };
|
||||
|
||||
// Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which
|
||||
// composes handlers rather than dropping them — spreading `{...rest}` separately would let the
|
||||
// getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the
|
||||
// caller's first (e.g. a stopPropagation), then the select.
|
||||
const mergedClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
rest.onClick?.(e);
|
||||
if (!disabled) onSelect?.();
|
||||
};
|
||||
const handlers = ctx
|
||||
? ctx.getItemProps({ ...rest, onClick: mergedClick })
|
||||
: { ...rest, onClick: mergedClick };
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...ariaProps}
|
||||
{...handlers}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
|
||||
// and disabled/className/data-active are the platform's to set.
|
||||
tabIndex={active ? 0 : -1}
|
||||
data-active={active ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PopoverSurfaceProps {
|
||||
popover: AnchoredPopover;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
/** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
|
||||
* take its name from. */
|
||||
ariaLabel?: string;
|
||||
focusManaged?: boolean;
|
||||
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
||||
/** The surface never receives pointer events — for a passive caption that must not swallow a click
|
||||
* aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable
|
||||
* content, links) must NOT set this. */
|
||||
passthrough?: boolean;
|
||||
}
|
||||
|
||||
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
|
||||
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
|
||||
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
|
||||
* surface out (e.g. a hover card).
|
||||
*
|
||||
* A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10
|
||||
* per table row) must not subscribe fullscreen listeners or build a portal container. */
|
||||
export function PopoverSurface(props: PopoverSurfaceProps) {
|
||||
if (!props.popover.open) return null;
|
||||
return <PopoverSurfaceInner {...props} />;
|
||||
}
|
||||
|
||||
function PopoverSurfaceInner({
|
||||
popover,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
focusManaged,
|
||||
initialFocus,
|
||||
passthrough,
|
||||
ariaLabel,
|
||||
}: PopoverSurfaceProps) {
|
||||
const {
|
||||
context,
|
||||
refs,
|
||||
floatingStyles,
|
||||
getFloatingProps,
|
||||
getItemProps,
|
||||
nodeId,
|
||||
referenceHidden,
|
||||
isPositioned,
|
||||
mode,
|
||||
isNav,
|
||||
selectedIndex,
|
||||
activeIndex,
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
} = popover;
|
||||
|
||||
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
||||
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
||||
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
|
||||
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
|
||||
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
|
||||
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
|
||||
// remount instead would rebuild the subtree and lose all of that.)
|
||||
const fsRoot = useFullscreenElement();
|
||||
const [container] = useState<HTMLDivElement | null>(() =>
|
||||
typeof document === "undefined" ? null : document.createElement("div"),
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (container) (fsRoot ?? document.body).appendChild(container);
|
||||
}, [container, fsRoot]);
|
||||
useEffect(() => () => container?.remove(), [container]);
|
||||
|
||||
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
||||
style?: CSSProperties;
|
||||
} & Record<string, unknown>;
|
||||
// useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label
|
||||
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// Everything but a passive hover card manages focus by default (trap into the surface, return to
|
||||
// the trigger on close).
|
||||
const managed = focusManaged ?? mode !== "hover";
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
{...floatingProps}
|
||||
aria-label={ariaLabel}
|
||||
className={zClass}
|
||||
style={{
|
||||
...floatingPropStyle,
|
||||
...floatingStyles,
|
||||
// Hide until the first position is measured (else it flashes at 0,0 before the transform
|
||||
// lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
|
||||
...(!isPositioned || referenceHidden
|
||||
? { visibility: "hidden" as const, pointerEvents: "none" as const }
|
||||
: null),
|
||||
...(passthrough ? { pointerEvents: "none" as const } : null),
|
||||
}}
|
||||
>
|
||||
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
|
||||
positioned element is essential: the popIn keyframe animates `transform`, which would
|
||||
otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
|
||||
for the animation's duration — the "flash in the top-left corner" bug. */}
|
||||
<div className={className} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
|
||||
// <PopoverItem>s can self-register and rove.
|
||||
const positioned = isNav ? (
|
||||
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
|
||||
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
||||
{surface}
|
||||
</FloatingList>
|
||||
</PopoverListContext.Provider>
|
||||
) : (
|
||||
surface
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingNode id={nodeId}>
|
||||
<FloatingPortal root={container ?? undefined}>
|
||||
{managed ? (
|
||||
<FloatingFocusManager
|
||||
context={context}
|
||||
modal={false}
|
||||
// Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
|
||||
// (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
|
||||
// suppress the manager's initial focus with -1. For a plain nav menu we let the manager
|
||||
// focus normally: it lands on the first tabbable child, which is the first roving item, or
|
||||
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
|
||||
// input, so such a menu still has a keyboard entry point instead of stranding focus.
|
||||
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
|
||||
returnFocus
|
||||
>
|
||||
{positioned}
|
||||
</FloatingFocusManager>
|
||||
) : (
|
||||
positioned
|
||||
)}
|
||||
</FloatingPortal>
|
||||
</FloatingNode>
|
||||
);
|
||||
}
|
||||
@@ -22,11 +22,9 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import { api, type FeedFilters, type Me } 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
|
||||
@@ -168,31 +166,6 @@ export default function ChannelPage({
|
||||
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
|
||||
});
|
||||
|
||||
// Personal tags, editable right here (the same control + flow the channel manager uses). Optimistic
|
||||
// on this channel's detail cache; a failed attach/detach reverts by refetching, and a success
|
||||
// invalidates the manager list so a tag added here shows there too.
|
||||
// Only fetch the tag catalog when the editor will actually render (subscribed, non-demo) — an
|
||||
// unsubscribed/demo channel page never shows the control, so the request would be thrown away.
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: api.tags,
|
||||
enabled: !!ch?.subscribed && !me.is_demo,
|
||||
});
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
qc.setQueryData<ChannelDetail>(qk.channel(id), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
tag_ids: attach ? [...old.tag_ids, tagId] : old.tag_ids.filter((x) => x !== tagId),
|
||||
}
|
||||
: old,
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
onError: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
});
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channel.unsubTitle"),
|
||||
@@ -438,19 +411,6 @@ export default function ChannelPage({
|
||||
{t("channel.subscribe")}
|
||||
</button>
|
||||
))}
|
||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||
channel you actually follow. */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
attachedIds={ch.tag_ids}
|
||||
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(channelId, name)}
|
||||
// The header row has free width — show tags inline instead of collapsing to "+N".
|
||||
bounded={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
|
||||
// (which grows the chip row) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
|
||||
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
|
||||
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
|
||||
// no longer gated on the top-of-page "Manage tags" dialog.
|
||||
|
||||
interface TagChipProps {
|
||||
tag: Tag;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const cls =
|
||||
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${cls} hover:opacity-80 transition`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className={cls}>{tag.name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
||||
* detail page in Part 2; internal for now.) */
|
||||
function TagEditorMenu({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState("");
|
||||
const fade = useScrollFade();
|
||||
const attached = new Set(attachedIds);
|
||||
const submit = () => {
|
||||
const name = draft.trim();
|
||||
if (!name) return;
|
||||
onCreate(name);
|
||||
setDraft("");
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-[240px] overflow-y-auto no-scrollbar">
|
||||
{userTags.map((tg) => {
|
||||
const on = attached.has(tg.id);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={tg.id}
|
||||
// Several tags can be on at once — a checkbox set, not one exclusive choice.
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() => onToggle(tg.id)}
|
||||
label={tg.name}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
<span className="truncate">{tg.name}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{userTags.length === 0 && (
|
||||
<p className="text-[11px] text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
|
||||
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
|
||||
// list-navigation/typeahead handlers on the surrounding surface.
|
||||
if (e.key === "Escape") return;
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
maxLength={64}
|
||||
placeholder={t("channels.tags.createPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
title={t("channels.tags.create")}
|
||||
aria-label={t("channels.tags.create")}
|
||||
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
|
||||
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
|
||||
* so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
export function ChannelTagsControl({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
bounded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
|
||||
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
|
||||
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
|
||||
// rem boxes, never a px guess.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(attached.length);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!row || !measure) return;
|
||||
const compute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const pill = nodes[nodes.length - 1];
|
||||
if (!pill) return;
|
||||
const pillW = pill.offsetWidth;
|
||||
const chips = nodes.slice(0, -1);
|
||||
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
|
||||
// between the last chip and the sample pill, rather than assuming a px constant.
|
||||
const lastChip = chips[chips.length - 1];
|
||||
const gap = lastChip
|
||||
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
|
||||
: 0;
|
||||
const avail = row.clientWidth;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
|
||||
// pill is needed. The reservation loop below reserves the pill's width for every chip except
|
||||
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
|
||||
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
|
||||
// chip narrower than the pill), leaving "+N" beside empty space.
|
||||
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
|
||||
if (lastRight <= avail) {
|
||||
setVisible(chips.length);
|
||||
return;
|
||||
}
|
||||
let count = chips.length;
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const chip = chips[i];
|
||||
if (!chip) break;
|
||||
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
|
||||
const moreAfter = i < chips.length - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setVisible(count);
|
||||
};
|
||||
compute();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
}, [tagsKey]);
|
||||
|
||||
const hidden = attached.length - visible;
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const editPop = useAnchoredPopover({
|
||||
open: editorOpen,
|
||||
onOpenChange: setEditorOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
|
||||
// of the roving list so neither steals the field's caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
|
||||
// popover's state closed so it can't linger open against a gone reference.
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
const pillClass =
|
||||
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
|
||||
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-[12rem] max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip key={tg.id} tag={tg} />
|
||||
))}
|
||||
<span className={pillClass}>+{attached.length}</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
attachedIds={attachedIds}
|
||||
onToggle={onToggle}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -12,14 +12,17 @@ import {
|
||||
History,
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Table,
|
||||
UserMinus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type ManagedChannel } from "../lib/api";
|
||||
import { api, type ManagedChannel, type Tag } 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";
|
||||
@@ -27,8 +30,7 @@ import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import Overlay from "./Overlay";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
@@ -196,25 +198,21 @@ 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. The
|
||||
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
||||
const { toggleTag, createAndAttachTag } = useChannelTagActions({
|
||||
optimisticToggle: (id, tagId, attach) =>
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) =>
|
||||
ch.id === id
|
||||
? {
|
||||
...ch,
|
||||
tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
|
||||
}
|
||||
: ch,
|
||||
),
|
||||
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
|
||||
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
|
||||
(old ?? []).map((ch) =>
|
||||
ch.id === id
|
||||
? {
|
||||
...ch,
|
||||
tag_ids: hasTag ? ch.tag_ids.filter((x) => x !== tagId) : [...ch.tag_ids, tagId],
|
||||
}
|
||||
: ch,
|
||||
),
|
||||
// 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 call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
|
||||
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
|
||||
};
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
@@ -455,21 +453,17 @@ 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) => (
|
||||
<ChannelTagsControl
|
||||
<TagsCell
|
||||
c={c}
|
||||
userTags={userTags}
|
||||
attachedIds={c.tag_ids}
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onTagClick={onFilterByTag}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -995,7 +989,7 @@ function SyncCell({ c }: { c: ManagedChannel }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<SyncBadge
|
||||
ok={c.recent_synced}
|
||||
label={t("channels.row.recent")}
|
||||
@@ -1030,6 +1024,187 @@ function SyncCell({ c }: { c: ManagedChannel }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TagsCell({
|
||||
c,
|
||||
userTags,
|
||||
onToggleTag,
|
||||
onTagClick,
|
||||
}: {
|
||||
c: ManagedChannel;
|
||||
userTags: Tag[];
|
||||
onToggleTag: (tagId: number) => void;
|
||||
onTagClick: (tagId: number, name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
||||
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
||||
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
|
||||
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
|
||||
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
|
||||
null,
|
||||
);
|
||||
const fade = useScrollFade();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
||||
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
||||
// Keep the scroll-fade hook's callback ref working alongside our own.
|
||||
const setPopRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
fade.ref(node);
|
||||
},
|
||||
[fade.ref],
|
||||
);
|
||||
|
||||
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
|
||||
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
|
||||
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
|
||||
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
|
||||
// re-runs this once it is.
|
||||
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
|
||||
const w = popRef.current?.offsetWidth ?? 0;
|
||||
const left =
|
||||
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
|
||||
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
|
||||
}, []);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return false;
|
||||
setCoords(coordsFrom(r, false));
|
||||
return true;
|
||||
}, [coordsFrom]);
|
||||
const toggle = () => {
|
||||
if (open) setOpen(false);
|
||||
else if (place()) setOpen(true);
|
||||
};
|
||||
|
||||
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
|
||||
// there is room above), and apply the width clamp now that the menu can actually be measured.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!open || !pop || !coords || !r) return;
|
||||
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
|
||||
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
|
||||
const fitsAbove = r.top - 4 - h >= 8;
|
||||
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
|
||||
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
|
||||
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
|
||||
const above = coords.bottom !== undefined;
|
||||
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
||||
const next = coordsFrom(r, flip);
|
||||
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
|
||||
setCoords(next);
|
||||
}
|
||||
}, [open, coords, coordsFrom]);
|
||||
|
||||
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
|
||||
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
|
||||
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
|
||||
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
|
||||
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
|
||||
// Scrolling INSIDE the menu must not move it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let raf = 0;
|
||||
const reposition = () => {
|
||||
raf = 0;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
const scroller = document.querySelector("main")?.getBoundingClientRect();
|
||||
const topEdge = scroller ? scroller.top : 0;
|
||||
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
|
||||
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
|
||||
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
|
||||
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
|
||||
};
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, coordsFrom]);
|
||||
if (userTags.length === 0) return null;
|
||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
|
||||
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
|
||||
return (
|
||||
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
{attached.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onTagClick(tg.id, tg.name)}
|
||||
title={t("channels.row.filterFeedByTag", { name: tg.name })}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggle}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{open && coords && (
|
||||
<Overlay>
|
||||
<div
|
||||
ref={setPopRef}
|
||||
style={{
|
||||
...fade.style,
|
||||
position: "fixed",
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
bottom: coords.bottom,
|
||||
}}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsCell({
|
||||
c,
|
||||
isAdmin,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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, 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.
|
||||
// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left
|
||||
// nav's bottom icon cluster — an icon-only button whose menu is portaled to <body> and
|
||||
// anchored to the right + above the button, escaping the nav's backdrop-filter which would
|
||||
// otherwise trap an absolutely-positioned popover).
|
||||
export default function LanguageSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
@@ -23,68 +22,128 @@ export default function LanguageSwitcher({
|
||||
variant?: "header" | "rail";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentIdx = Math.max(
|
||||
0,
|
||||
LANGUAGES.findIndex((l) => l.code === value),
|
||||
);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
||||
const rail = variant === "rail";
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
|
||||
offset: rail ? 8 : 4,
|
||||
// Single choice: opening lands on the language already in use.
|
||||
selectedIndex: currentIdx,
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
className={
|
||||
rail
|
||||
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
|
||||
}
|
||||
>
|
||||
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
|
||||
{rail ? (
|
||||
/* Current-language badge (HU/EN) so the active language reads at a glance without
|
||||
opening the menu. The ring matches the page background to detach it from the icon. */
|
||||
// Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check
|
||||
// spans both the button and the floating panel).
|
||||
useEffect(() => {
|
||||
if (variant !== "rail" || !open) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [variant, open]);
|
||||
|
||||
const menu = (
|
||||
<div ref={panelRef} className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
onChange(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<span>{l.label}</span>
|
||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "rail") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggleRail}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Globe className="w-5 h-5" />
|
||||
{/* Current-language badge (HU/EN) so the active language reads at a glance without
|
||||
opening the menu. The ring matches the page background to detach it from the icon. */}
|
||||
<span className="absolute -bottom-1 -right-1 text-[8px] font-bold leading-none px-1 py-[1px] rounded bg-accent text-accent-fg ring-2 ring-bg">
|
||||
{current.code.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase">{current.code}</span>
|
||||
)}
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
className="z-rail"
|
||||
style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}
|
||||
>
|
||||
{menu}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" onMouseEnter={openNow} onMouseLeave={closeSoon}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={current.label}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<span className="text-xs font-semibold uppercase">{current.code}</span>
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<PopoverItem
|
||||
key={l.code}
|
||||
role="menuitemradio"
|
||||
checked={l.code === value}
|
||||
onSelect={() => {
|
||||
onChange(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={l.label}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-none transition hover:text-fg hover:bg-card data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg"
|
||||
>
|
||||
<span>{l.label}</span>
|
||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
{open && (
|
||||
<div
|
||||
className={`glass-menu absolute ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
} mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => {
|
||||
onChange(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<span>{l.label}</span>
|
||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,20 @@ 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";
|
||||
|
||||
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
|
||||
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
|
||||
// dismisses just the error and returns to the editor underneath, and a player below defers to it
|
||||
// (R8 S3). Portals into `document.fullscreenElement ?? document.body`: a node under <body> is painted
|
||||
// beneath whatever holds the fullscreen lock, so a Modal opened from a player in true fullscreen (the
|
||||
// download dialog) would otherwise open invisibly.
|
||||
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
|
||||
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
|
||||
let modalStack: number[] = [];
|
||||
let nextModalId = 1;
|
||||
|
||||
/** How many <Modal>s are currently open. The players (which use their own window-level Escape
|
||||
* listeners, not this stack) check it so their Escape defers to a Modal opened above them —
|
||||
* otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */
|
||||
export function modalCount(): number {
|
||||
return modalStack.length;
|
||||
}
|
||||
|
||||
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
|
||||
export default function Modal({
|
||||
title,
|
||||
onClose,
|
||||
@@ -26,7 +31,6 @@ 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
|
||||
@@ -37,9 +41,10 @@ export default function Modal({
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
const id = nextModalId++;
|
||||
modalStack.push(id);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
}
|
||||
@@ -49,7 +54,7 @@ export default function Modal({
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
removeLayer(id);
|
||||
modalStack = modalStack.filter((x) => x !== id);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, []);
|
||||
@@ -83,6 +88,6 @@ export default function Modal({
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
fsRoot ?? document.body,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { useEffect, useRef, 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";
|
||||
@@ -24,7 +25,6 @@ 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,20 +57,41 @@ export default function NavSidebar({
|
||||
const expanded = pinned || peek.active;
|
||||
const slim = !expanded; // visual: icon-only rail
|
||||
const [acctOpen, setAcctOpen] = useState(false);
|
||||
// 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,
|
||||
});
|
||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const acctPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
// Popover position (fixed, viewport coords). It's portaled to <body> so it escapes the
|
||||
// nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let
|
||||
// clicks fall through to the controls behind it.
|
||||
const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
|
||||
|
||||
function toggleAccount() {
|
||||
if (!acctOpen) {
|
||||
const r = acctBtnRef.current?.getBoundingClientRect();
|
||||
if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom });
|
||||
}
|
||||
setAcctOpen((o) => !o);
|
||||
}
|
||||
|
||||
// Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop
|
||||
// div, which (sitting between the popover and the page) would break the popover's
|
||||
// backdrop-filter and make it look solid.
|
||||
useEffect(() => {
|
||||
if (!acctOpen) return;
|
||||
function onDoc(e: MouseEvent) {
|
||||
const t = e.target as Node;
|
||||
if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return;
|
||||
setAcctOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setAcctOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDoc);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [acctOpen]);
|
||||
|
||||
async function logout() {
|
||||
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
|
||||
@@ -317,10 +338,10 @@ export default function NavSidebar({
|
||||
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={acctPop.refs.setReference}
|
||||
{...acctPop.getReferenceProps()}
|
||||
ref={acctBtnRef}
|
||||
onClick={toggleAccount}
|
||||
title={slim ? `${name} · ${roleLabel}` : undefined}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} text-muted hover:text-fg hover:bg-card`}
|
||||
>
|
||||
@@ -338,75 +359,79 @@ export default function NavSidebar({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={acctPop}
|
||||
ariaLabel={name}
|
||||
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
src={me.avatar_url}
|
||||
fallback={name}
|
||||
className="w-10 h-10 rounded-full text-sm shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t("header.account.admin")}
|
||||
</div>
|
||||
)}
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
{acctOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={acctPanelRef}
|
||||
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
|
||||
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
src={me.avatar_url}
|
||||
fallback={name}
|
||||
className="w-10 h-10 rounded-full text-sm shrink-0"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{name}</div>
|
||||
<div className="text-xs text-muted truncate">{me.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
{me.role === "admin" && (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-[11px] font-medium text-accent">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{t("header.account.admin")}
|
||||
</div>
|
||||
)}
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-[10px] shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="block text-[13px] text-fg truncate">
|
||||
{a.display_name ?? a.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
clearActiveAccount();
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-[10px] shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="block text-[13px] text-fg truncate">
|
||||
{a.display_name ?? a.email.split("@")[0]}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
// and pins that. Other tabs keep their own pinned account.
|
||||
clearActiveAccount();
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { modalCount } from "./Modal";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
|
||||
@@ -60,32 +59,6 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
const FINISH_MARGIN = 10;
|
||||
|
||||
// Force a high starting resolution. YouTube decides quality at player-CREATION time from the
|
||||
// iframe's INTERNAL viewport size, then latches it. A windowed-size embed (~900×500) is stuck at
|
||||
// ~360–480p, which is why the video used to open low and needed a manual fullscreen + gear trip to
|
||||
// 1080p.
|
||||
//
|
||||
// Two phases (see applyHdPhase1 / switchToHdNative / the quality poll):
|
||||
// 1. Create the iframe at this large FIXED logical size and CSS `transform: scale()` it DOWN to fit
|
||||
// the visible box. A CSS transform does NOT change the iframe's internal viewport, so YouTube
|
||||
// still sees 1920×1080 and latches 1080p — even while it paints small. (Its own controls, incl.
|
||||
// the gear, also paint scaled-down in the windowed box — the one downside of phase 1 alone.)
|
||||
// 2. Once 1080p has actually latched, drop to NATIVE responsive sizing (setSize) so YouTube renders
|
||||
// its controls at full size.
|
||||
//
|
||||
// ⚠️ CONDITIONAL / UNDER OBSERVATION (2026-07-31): phase 2 is NOT proven safe everywhere. In dev, on
|
||||
// ONE `was_live` stream, quality dropped hd1080 → large (480p) ~2s after going native (YouTube
|
||||
// re-caps to the now-small internal viewport); on the author's own machine/connection it HELD 1080p
|
||||
// on a normal VOD. So it's environment-/video-dependent and is being watched in prod. Two levers:
|
||||
// • HD_DROP_TO_NATIVE = false → skip phase 2, keep phase 1 forever = GUARANTEED 1080p (the viewport
|
||||
// stays 1920), at the cost of the scaled-down controls. The safe fallback if phase 2 downgrades.
|
||||
// • FORCE_HD_LOGICAL_SIZE = false → disable the whole trick, back to responsive 100%/100% sizing.
|
||||
// 16:9 to match the `aspect-video` stage.
|
||||
const FORCE_HD_LOGICAL_SIZE = true;
|
||||
const HD_DROP_TO_NATIVE = true;
|
||||
const HD_W = 1920;
|
||||
const HD_H = 1080;
|
||||
|
||||
// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
|
||||
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
|
||||
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
|
||||
@@ -94,108 +67,6 @@ type LoopMode = "off" | "one" | "all";
|
||||
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
||||
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
||||
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
function PlayerTransportControls({
|
||||
variant,
|
||||
index,
|
||||
queueLength,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCycleAuto,
|
||||
onCycleLoop,
|
||||
}: {
|
||||
variant: "docked" | "modal";
|
||||
index: number;
|
||||
queueLength: number;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onCycleAuto: () => void;
|
||||
onCycleLoop: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const docked = variant === "docked";
|
||||
const navBtn = docked
|
||||
? "inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||
: "inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition";
|
||||
const navLabel = docked ? "hidden sm:inline" : "";
|
||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const toggle = (on: boolean) =>
|
||||
docked
|
||||
? `inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
on ? "bg-white/20 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`
|
||||
: `inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
on ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
|
||||
}`;
|
||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className={navBtn}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
<span className={navLabel}>{t("player.previous")}</span>
|
||||
</button>
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className={navBtn}
|
||||
>
|
||||
<span className={navLabel}>{t("player.next")}</span>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<button
|
||||
onClick={onCycleAuto}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={toggle(autoMode !== "off")}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onCycleLoop}
|
||||
title={t("player.loop.hint")}
|
||||
className={toggle(loopMode !== "off")}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className={toggleLabel}>
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Can `el` (or an ancestor inside the modal) still scroll vertically in `dir` (-1 up, 1 down)?
|
||||
* Used to leave ArrowUp/ArrowDown alone while there is content to scroll — the volume shortcut
|
||||
* must not eat the only keyboard way to read a long description. Stops at `<body>`: the page
|
||||
@@ -289,13 +160,6 @@ export default function PlayerModal({
|
||||
const resumeAt =
|
||||
startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
// The box the (fixed HD-sized) player iframe is scaled to fit — see FORCE_HD_LOGICAL_SIZE.
|
||||
const videoBoxRef = useRef<HTMLDivElement | null>(null);
|
||||
// Rescales the HD iframe into videoBoxRef; set up once (ResizeObserver) and also called on ready.
|
||||
const fitPlayerRef = useRef<(() => void) | null>(null);
|
||||
// HD phase: false = phase 1 (fixed 1920×1080 iframe scaled down, to latch 1080p); true = phase 2
|
||||
// (native responsive sizing, so YouTube's own controls render full-size once 1080p has latched).
|
||||
const hdNativeRef = useRef(false);
|
||||
const playerRef = useRef<YTPlayer | null>(null);
|
||||
const autoMarkedRef = useRef(false);
|
||||
// Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
|
||||
@@ -456,42 +320,6 @@ export default function PlayerModal({
|
||||
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
|
||||
const [playerError, setPlayerError] = useState<number | null>(null);
|
||||
|
||||
// Put the HD iframe into phase 1 (fixed 1920×1080, scaled to fit) so YouTube (re)latches 1080p from
|
||||
// a full-HD internal viewport. Called on player-ready and when a NEW video is loaded into the same
|
||||
// player (a description link) — otherwise the new clip would start at the native box's low quality.
|
||||
const applyHdPhase1 = () => {
|
||||
if (!FORCE_HD_LOGICAL_SIZE) return;
|
||||
hdNativeRef.current = false;
|
||||
const f = playerRef.current?.getIframe?.();
|
||||
if (f) {
|
||||
// Anchor the fixed-HD iframe top-left of its box so the scale-transform (fitPlayerRef) fits it
|
||||
// in; `maxWidth:none` guards against a global iframe { max-width:100% } reset.
|
||||
f.style.position = "absolute";
|
||||
f.style.top = "0";
|
||||
f.style.left = "0";
|
||||
f.style.width = `${HD_W}px`;
|
||||
f.style.height = `${HD_H}px`;
|
||||
f.style.maxWidth = "none";
|
||||
f.style.transformOrigin = "0 0";
|
||||
}
|
||||
fitPlayerRef.current?.();
|
||||
};
|
||||
// Once 1080p has latched (see the quality poll), drop to native responsive sizing so YouTube renders
|
||||
// its own controls — the gear especially — at full size. Gated by HD_DROP_TO_NATIVE (see the note at
|
||||
// the top): if phase 2 proves to downgrade in the wild, flip that off to keep phase 1 forever.
|
||||
const switchToHdNative = () => {
|
||||
const f = playerRef.current?.getIframe?.();
|
||||
if (!f) return;
|
||||
hdNativeRef.current = true;
|
||||
f.style.position = "";
|
||||
f.style.top = "";
|
||||
f.style.left = "";
|
||||
f.style.transform = "none";
|
||||
f.style.transformOrigin = "";
|
||||
f.style.maxWidth = "";
|
||||
fitPlayerRef.current?.(); // now runs the native setSize branch
|
||||
};
|
||||
|
||||
const loadVideo = (id: string, start: number | null) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.loadVideoById !== "function") return;
|
||||
@@ -499,9 +327,6 @@ export default function PlayerModal({
|
||||
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
|
||||
setPlayerError(null);
|
||||
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
|
||||
// Re-latch 1080p for the newly-loaded clip: back to phase 1 (the quality poll flips it to native
|
||||
// once 1080p lands). Without this a description-link video would inherit the native box's low pick.
|
||||
applyHdPhase1();
|
||||
currentIdRef.current = id;
|
||||
setCurrentVideoId(id);
|
||||
setLiveData(null);
|
||||
@@ -621,21 +446,41 @@ export default function PlayerModal({
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Lazy description (fetched only when the title is hovered). The popover is
|
||||
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
|
||||
// can't clip it. A small close grace lets the mouse travel title → popover.
|
||||
const [showDesc, setShowDesc] = useState(false);
|
||||
const descPop = useAnchoredPopover({
|
||||
open: showDesc,
|
||||
onOpenChange: setShowDesc,
|
||||
mode: "hover",
|
||||
placement: "top-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 400, close: 150 },
|
||||
});
|
||||
// `bottom` anchors the popover just above the title so it grows upward (over the
|
||||
// player) instead of downward off-screen / behind the OS taskbar.
|
||||
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
|
||||
null,
|
||||
);
|
||||
const titleRef = useRef<HTMLSpanElement | null>(null);
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const openTimer = useRef<number | undefined>(undefined);
|
||||
const openDescNow = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
const el = titleRef.current;
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const width = Math.min(560, window.innerWidth - 32);
|
||||
const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
|
||||
setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
|
||||
}
|
||||
setShowDesc(true);
|
||||
};
|
||||
// Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
|
||||
// quick pass over it doesn't flash the popover.
|
||||
const scheduleOpenDesc = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
window.clearTimeout(openTimer.current);
|
||||
openTimer.current = window.setTimeout(openDescNow, 400);
|
||||
};
|
||||
const scheduleCloseDesc = () => {
|
||||
window.clearTimeout(openTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
|
||||
};
|
||||
const detail = useQuery({
|
||||
queryKey: qk.videoDetail(currentVideoId),
|
||||
queryFn: () => api.videoDetail(currentVideoId),
|
||||
@@ -645,28 +490,12 @@ export default function PlayerModal({
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
|
||||
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
layerIdRef.current = id;
|
||||
return () => {
|
||||
removeLayer(id);
|
||||
layerIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it.
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
// Same for our own maximise: Esc steps back out of it before it closes the player.
|
||||
@@ -675,6 +504,9 @@ 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;
|
||||
}
|
||||
@@ -728,6 +560,8 @@ 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.
|
||||
@@ -804,53 +638,12 @@ export default function PlayerModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the fixed-HD player iframe scaled to fit its visible box (see FORCE_HD_LOGICAL_SIZE). The
|
||||
// iframe stays 1920×1080 internally (so YouTube serves 1080p) while we CSS-scale it DOWN to the
|
||||
// box with a letterbox-`contain` fit — one factor + centring handles both the 16:9 windowed box
|
||||
// and the non-16:9 big-view box (viewport minus the 56px control bar). Bound once; reads live refs.
|
||||
useEffect(() => {
|
||||
if (!FORCE_HD_LOGICAL_SIZE) return;
|
||||
const box = videoBoxRef.current;
|
||||
if (!box) return;
|
||||
const fit = () => {
|
||||
const p = playerRef.current;
|
||||
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
|
||||
if (!f) return;
|
||||
const bw = box.clientWidth;
|
||||
const bh = box.clientHeight;
|
||||
if (!bw || !bh) return;
|
||||
if (hdNativeRef.current) {
|
||||
// Phase 2: native responsive sizing — the iframe IS the box, so YouTube's controls render
|
||||
// at full size. Set the inline size explicitly too: phase 1 left an inline 1920×1080px on the
|
||||
// element, which would otherwise override setSize's attributes and keep it overflowing.
|
||||
f.style.width = `${bw}px`;
|
||||
f.style.height = `${bh}px`;
|
||||
if (p && typeof p.setSize === "function") p.setSize(bw, bh);
|
||||
return;
|
||||
}
|
||||
// Phase 1: latch 1080p — scale the fixed 1920×1080 iframe DOWN to fit, letterbox-`contain`.
|
||||
const k = Math.min(bw / HD_W, bh / HD_H);
|
||||
const cx = (bw - HD_W * k) / 2;
|
||||
const cy = (bh - HD_H * k) / 2;
|
||||
f.style.transform = `translate(${cx}px, ${cy}px) scale(${k})`;
|
||||
};
|
||||
fitPlayerRef.current = fit;
|
||||
const ro = new ResizeObserver(fit);
|
||||
ro.observe(box);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
fitPlayerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create the player, resume from the saved position, persist progress, and
|
||||
// auto-mark watched once playback reaches the end.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = active.id;
|
||||
autoMarkedRef.current = false;
|
||||
// A fresh player starts in HD phase 1 (scaled), regardless of what the previous video ended on.
|
||||
hdNativeRef.current = false;
|
||||
setPlayerError(null);
|
||||
|
||||
// Auto-watch only applies to the active item — not to other videos the player
|
||||
@@ -890,10 +683,8 @@ export default function PlayerModal({
|
||||
loadYouTubeApi().then((YT) => {
|
||||
if (cancelled || !mountRef.current) return;
|
||||
playerRef.current = new YT.Player(mountRef.current, {
|
||||
// A FIXED large logical size (scaled down to fit via fitPlayerRef) so YouTube starts at
|
||||
// 1080p; falls back to the old responsive sizing if the trick is disabled. See HD_W/HD_H.
|
||||
width: FORCE_HD_LOGICAL_SIZE ? String(HD_W) : "100%",
|
||||
height: FORCE_HD_LOGICAL_SIZE ? String(HD_H) : "100%",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
videoId: id,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
@@ -904,21 +695,16 @@ export default function PlayerModal({
|
||||
playsinline: 1,
|
||||
// Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
|
||||
// now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
|
||||
// honoured — kept because it's harmless. The real HD lever is the fixed HD_W×HD_H iframe
|
||||
// size above (see FORCE_HD_LOGICAL_SIZE): YouTube reads quality from the iframe's INTERNAL
|
||||
// viewport at creation, which a CSS transform-scale does not shrink — so scaling a LARGE
|
||||
// player DOWN keeps the 1080p pick (an earlier note here that "the scale trick fails" was
|
||||
// scaling a SMALL player UP, which genuinely doesn't fool the cap; the opposite direction
|
||||
// does — measured 2026-07-31).
|
||||
// honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
|
||||
// the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
|
||||
// lifts the cap (after which the higher pick persists for the session). We can't beat that
|
||||
// programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
|
||||
vq: "hd1080",
|
||||
},
|
||||
events: {
|
||||
// Keep keyboard focus on the modal, not the iframe, so shortcuts work right away, and
|
||||
// restore the remembered volume — a fresh player always starts at 100.
|
||||
onReady: () => {
|
||||
// Start in phase 1 (fixed-HD iframe scaled to fit) so YouTube latches 1080p; the quality
|
||||
// poll below flips to native sizing once it lands.
|
||||
applyHdPhase1();
|
||||
focusModal();
|
||||
applyVolume(volumeRef.current, false);
|
||||
},
|
||||
@@ -961,18 +747,6 @@ export default function PlayerModal({
|
||||
|
||||
// Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
|
||||
const timer = window.setInterval(persist, 5000);
|
||||
// HD phase 2 trigger: once the (phase-1, scaled) player has actually latched 1080p, drop to native
|
||||
// sizing so YouTube's controls render full-size. Only at hd1080+. Gated by HD_DROP_TO_NATIVE — the
|
||||
// conditional/under-observation switch (see the note up top): a video that never reaches 1080p, a
|
||||
// constrained network, or HD_DROP_TO_NATIVE=false all keep phase 1 (the guaranteed-1080p fallback).
|
||||
// Re-arms automatically after a description-link load (applyHdPhase1 resets the flag).
|
||||
const HD_NATIVE_QUALITIES = new Set(["hd1080", "hd1440", "hd2160"]);
|
||||
const hdPoll = window.setInterval(() => {
|
||||
if (!FORCE_HD_LOGICAL_SIZE || !HD_DROP_TO_NATIVE || hdNativeRef.current) return;
|
||||
const p = playerRef.current;
|
||||
const q = p && typeof p.getPlaybackQuality === "function" ? p.getPlaybackQuality() : "";
|
||||
if (HD_NATIVE_QUALITIES.has(q)) switchToHdNative();
|
||||
}, 700);
|
||||
// Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
|
||||
// without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
|
||||
// beacon so the POST survives the unload. Only for the library item we opened with — a navigated
|
||||
@@ -993,7 +767,6 @@ export default function PlayerModal({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
window.clearInterval(hdPoll);
|
||||
window.removeEventListener("pagehide", onHide);
|
||||
// Final flush, then refresh the feed + playlists so resume bars reflect this session.
|
||||
Promise.resolve(persist()).finally(() => {
|
||||
@@ -1065,11 +838,8 @@ export default function PlayerModal({
|
||||
YT.Player REPLACES the mount element with its iframe (width/height 100% of the parent),
|
||||
which would drop any class on mountRef itself — the iframe then sizes to this wrapper. */}
|
||||
<div
|
||||
ref={videoBoxRef}
|
||||
className={
|
||||
maximized || isFullscreen
|
||||
? "absolute inset-x-0 top-0 bottom-14 overflow-hidden"
|
||||
: "relative w-full h-full overflow-hidden"
|
||||
maximized || isFullscreen ? "absolute inset-x-0 top-0 bottom-14" : "w-full h-full"
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -1105,9 +875,8 @@ 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 works in true fullscreen (its
|
||||
popover portals INTO the fullscreen element); download stays hidden there because it opens
|
||||
a Modal, which still portals to <body> — outside the fullscreen element. */}
|
||||
the exit button also re-arms our shortcuts. Add-to-playlist / download are hidden in true
|
||||
fullscreen — their popovers portal to <body>, outside the fullscreen element. */}
|
||||
{(maximized || isFullscreen) && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -1115,30 +884,76 @@ export default function PlayerModal({
|
||||
>
|
||||
{hasQueue && (
|
||||
<>
|
||||
<PlayerTransportControls
|
||||
variant="docked"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("player.previous")}</span>
|
||||
</button>
|
||||
<span className="tabular-nums text-white/70">
|
||||
{index + 1} / {queue!.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={index === queue!.length - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className="inline-flex items-center gap-1 text-white/80 enabled:hover:text-white disabled:opacity-30 transition"
|
||||
>
|
||||
<span className="hidden sm:inline">{t("player.next")}</span>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
<button
|
||||
onClick={cycleAuto}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
autoMode !== "off"
|
||||
? "bg-white/20 text-white"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="hidden md:inline">
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleLoop}
|
||||
title={t("player.loop.hint")}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 transition ${
|
||||
loopMode !== "off"
|
||||
? "bg-white/20 text-white"
|
||||
: "text-white/70 hover:bg-white/10 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="hidden md:inline">
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="h-4 w-px bg-white/20" />
|
||||
</>
|
||||
)}
|
||||
{/* Both work in fullscreen now: add-to-playlist's popover and download's Modal both
|
||||
portal into the fullscreen element rather than <body> (a body-portaled node is
|
||||
painted underneath the element holding the lock, i.e. invisible). */}
|
||||
{!navigated && (
|
||||
{!navigated && !isFullscreen && (
|
||||
<AddToPlaylist
|
||||
videoId={active.id}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition"
|
||||
/>
|
||||
)}
|
||||
{!navigated && (
|
||||
{!navigated && !isFullscreen && (
|
||||
<DownloadButton
|
||||
videoId={active.id}
|
||||
title={active.title}
|
||||
@@ -1236,17 +1051,61 @@ export default function PlayerModal({
|
||||
|
||||
{hasQueue && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
|
||||
<PlayerTransportControls
|
||||
variant="modal"
|
||||
index={index}
|
||||
queueLength={queue!.length}
|
||||
autoMode={autoMode}
|
||||
loopMode={loopMode}
|
||||
onPrev={goPrev}
|
||||
onNext={goNext}
|
||||
onCycleAuto={cycleAuto}
|
||||
onCycleLoop={cycleLoop}
|
||||
/>
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={index === 0}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||
>
|
||||
<SkipBack className="w-4 h-4" /> {t("player.previous")}
|
||||
</button>
|
||||
<span className="text-muted tabular-nums">
|
||||
{index + 1} / {queue!.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={index === queue!.length - 1}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("player.next")} <SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-px h-4 bg-border" />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<button
|
||||
onClick={cycleAuto}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
autoMode !== "off"
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-muted hover:bg-surface hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{autoMode === "prev" ? (
|
||||
<SkipBack className="h-3.5 w-3.5" />
|
||||
) : autoMode === "random" ? (
|
||||
<Shuffle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<SkipForward className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</button>
|
||||
<button
|
||||
onClick={cycleLoop}
|
||||
title={t("player.loop.hint")}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
|
||||
loopMode !== "off"
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-muted hover:bg-surface hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{loopMode === "one" ? (
|
||||
<Repeat1 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1256,9 +1115,10 @@ export default function PlayerModal({
|
||||
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||
<span
|
||||
ref={descPop.refs.setReference}
|
||||
{...descPop.getReferenceProps()}
|
||||
ref={titleRef}
|
||||
className="cursor-default"
|
||||
onMouseEnter={scheduleOpenDesc}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||
</span>
|
||||
@@ -1273,32 +1133,43 @@ export default function PlayerModal({
|
||||
{t("player.back")}
|
||||
</button>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={descPop}
|
||||
className="w-[min(35rem,calc(100vw-2rem))] bg-surface border border-border rounded-xl shadow-2xl p-4"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||
{t("player.description")}
|
||||
</div>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.data?.description ? (
|
||||
{showDesc &&
|
||||
descRect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
|
||||
style={{
|
||||
left: descRect.left,
|
||||
bottom: descRect.bottom,
|
||||
width: descRect.width,
|
||||
}}
|
||||
onMouseEnter={openDescNow}
|
||||
onMouseLeave={scheduleCloseDesc}
|
||||
>
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||
{t("player.description")}
|
||||
</div>
|
||||
{detail.isLoading ? (
|
||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||
) : detail.data?.description ? (
|
||||
<div
|
||||
ref={descFade.ref}
|
||||
style={descFade.style}
|
||||
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||
>
|
||||
{renderDescription(detail.data.description, {
|
||||
currentId: currentVideoId,
|
||||
onSeek: seekTo,
|
||||
onLoadVideo: loadVideo,
|
||||
t,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</PopoverSurface>
|
||||
<button
|
||||
data-testid="player-close"
|
||||
onClick={onClose}
|
||||
@@ -1415,25 +1286,10 @@ export default function PlayerModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
||||
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
||||
the only fullscreen button previously reachable in the windowed video was YouTube's
|
||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||
the docked bar offers OUR true fullscreen. */}
|
||||
{!navigated && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={`${t("player.fullscreen")} · F`}
|
||||
aria-label={t("player.fullscreen")}
|
||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
>
|
||||
<Maximize className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!navigated && (
|
||||
<AddToPlaylist
|
||||
videoId={active.id}
|
||||
className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
|
||||
/>
|
||||
)}
|
||||
{!navigated && (
|
||||
|
||||
@@ -46,7 +46,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { modalCount } from "./Modal";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -808,18 +808,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
handleBack();
|
||||
}, [handleBack]);
|
||||
|
||||
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
|
||||
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
|
||||
const layerIdRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const id = pushLayer();
|
||||
layerIdRef.current = id;
|
||||
return () => {
|
||||
removeLayer(id);
|
||||
layerIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- keyboard --------------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -886,10 +874,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
else handleBack();
|
||||
break;
|
||||
case "Escape":
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C).
|
||||
if (modalCount() > 0) break;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
setMenuOpen(false);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
|
||||
// Per-user status (not the global catalog): shows the number of videos available to *this*
|
||||
// user, how many of *their* channels are still being fetched, and how many lack full
|
||||
@@ -20,17 +21,12 @@ export default function SyncStatus({
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
|
||||
// popover. It portals out of the header, whose glass backdrop-filter would otherwise trap its
|
||||
// stacking and positioning.
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-end",
|
||||
offset: 8,
|
||||
});
|
||||
const chipRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
// Fixed viewport coords: the popover is portaled out of the header, whose glass backdrop-filter
|
||||
// would otherwise trap its stacking and positioning.
|
||||
const [pos, setPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 });
|
||||
useDismiss(open, () => setOpen(false), [chipRef, popRef]);
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.myStatus(),
|
||||
queryFn: api.myStatus,
|
||||
@@ -110,11 +106,16 @@ export default function SyncStatus({
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
ref={chipRef}
|
||||
onClick={() => {
|
||||
const r = chipRef.current?.getBoundingClientRect();
|
||||
if (r) setPos({ top: r.bottom + 8, right: window.innerWidth - r.right });
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
// Short label only: the popover already explains the numbers, and the long one overflowed
|
||||
// the viewport edge from here.
|
||||
title={countsText}
|
||||
aria-expanded={open}
|
||||
className="glass glass-hover relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl text-xs text-muted"
|
||||
>
|
||||
{active ? (
|
||||
@@ -128,46 +129,50 @@ export default function SyncStatus({
|
||||
)}
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={countsText}
|
||||
className="glass-menu w-64 rounded-xl p-3 text-xs text-muted animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div
|
||||
title={t("header.sync.countTooltip")}
|
||||
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatViews(data.total_videos)} {t("header.sync.total")}
|
||||
</span>
|
||||
</div>
|
||||
{showMain && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||
{pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onGoToFullHistory();
|
||||
}}
|
||||
title={t("header.sync.fullHistoryTooltip")}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
style={{ position: "fixed", top: pos.top, right: pos.right }}
|
||||
className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div
|
||||
title={t("header.sync.countTooltip")}
|
||||
className="flex items-center gap-1.5 pb-2 border-b border-border/60"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatViews(data.total_videos)} {t("header.sync.total")}
|
||||
</span>
|
||||
</div>
|
||||
{showMain && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<span className="flex items-center gap-1.5 min-w-0">{stateNode}</span>
|
||||
{pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
{notFull > 0 && (
|
||||
<div className="flex items-center justify-between gap-1.5 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onGoToFullHistory();
|
||||
}}
|
||||
title={t("header.sync.fullHistoryTooltip")}
|
||||
className="flex items-center gap-1 hover:text-fg underline decoration-dotted decoration-muted/40 underline-offset-4 transition cursor-pointer"
|
||||
>
|
||||
<History className="w-3.5 h-3.5" />
|
||||
{t("header.sync.withoutFullHistory", { count: notFull })}
|
||||
</button>
|
||||
{!showMain && pauseBtn}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -8,12 +9,10 @@ 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 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.
|
||||
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it.
|
||||
function TagRow({
|
||||
tag,
|
||||
channels,
|
||||
@@ -30,20 +29,25 @@ function TagRow({
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 });
|
||||
const popFade = useScrollFade();
|
||||
const countRef = useRef<HTMLSpanElement | null>(null);
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const commit = () => {
|
||||
const v = name.trim();
|
||||
if (v && v !== tag.name) onRename(v);
|
||||
else setName(tag.name);
|
||||
};
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && channels.length > 0,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 0, close: 150 },
|
||||
});
|
||||
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);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -54,8 +58,9 @@ function TagRow({
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<span
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
ref={countRef}
|
||||
onMouseEnter={openPop}
|
||||
onMouseLeave={closeSoon}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
|
||||
@@ -72,30 +77,30 @@ function TagRow({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFade.ref}
|
||||
style={popFade.style}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popFade.ref}
|
||||
onMouseEnter={openPop}
|
||||
onMouseLeave={closeSoon}
|
||||
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
|
||||
className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
|
||||
>
|
||||
{ch.title}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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. 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. */
|
||||
* app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with
|
||||
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
|
||||
export default function Tooltip({
|
||||
hint,
|
||||
side = "top",
|
||||
@@ -18,34 +21,74 @@ export default function Tooltip({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||
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 },
|
||||
});
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
|
||||
function show() {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
// Prefer the requested side; flip to bottom if there's no room above.
|
||||
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
|
||||
const center = r.left + r.width / 2;
|
||||
// Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
|
||||
// first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
|
||||
// the centered box off-screen. The layout effect below refines this to the actual width.
|
||||
setCoords({
|
||||
left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
|
||||
top: placement === "top" ? r.top - 8 : r.bottom + 8,
|
||||
placement,
|
||||
});
|
||||
}
|
||||
function hide() {
|
||||
setCoords(null);
|
||||
}
|
||||
|
||||
// Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
|
||||
// need the full 240px reservation), still clamped inside the viewport.
|
||||
useLayoutEffect(() => {
|
||||
const tip = tipRef.current;
|
||||
const el = ref.current;
|
||||
if (!coords || !tip || !el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
const half = tip.offsetWidth / 2;
|
||||
const left = Math.min(
|
||||
Math.max(r.left + r.width / 2, half + MARGIN),
|
||||
window.innerWidth - half - MARGIN,
|
||||
);
|
||||
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
|
||||
}, [coords]);
|
||||
|
||||
if (!enabled || !hint) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
|
||||
overlaps while the hover-close delay runs out. */}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
passthrough
|
||||
className="glass w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
||||
>
|
||||
{hint}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
{coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
|
||||
}}
|
||||
className="glass pointer-events-none z-tooltip w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
|
||||
>
|
||||
{hint}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
|
||||
export interface ViewOption<T extends string> {
|
||||
id: T;
|
||||
@@ -13,11 +13,9 @@ export interface ViewOption<T extends string> {
|
||||
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
|
||||
// density modes; the managers' layouts) without re-deriving the menu behaviour.
|
||||
//
|
||||
// This 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.
|
||||
// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
|
||||
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
|
||||
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
|
||||
export default function ViewSwitcher<T extends string>({
|
||||
value,
|
||||
options,
|
||||
@@ -36,6 +34,19 @@ export default function ViewSwitcher<T extends string>({
|
||||
labelClassName?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
|
||||
const [focusIdx, setFocusIdx] = useState(0);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
function close({ refocus = true }: { refocus?: boolean } = {}) {
|
||||
setOpen(false);
|
||||
if (refocus) btnRef.current?.focus();
|
||||
}
|
||||
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
|
||||
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
|
||||
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
|
||||
|
||||
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
|
||||
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
|
||||
@@ -43,24 +54,55 @@ export default function ViewSwitcher<T extends string>({
|
||||
0,
|
||||
options.findIndex((o) => o.id === value),
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
// 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 current = options[currentIdx];
|
||||
if (!current) return null;
|
||||
const CurrentIcon = current.icon;
|
||||
|
||||
function onMenuKeyDown(e: React.KeyboardEvent) {
|
||||
const last = options.length - 1;
|
||||
if (e.key === "Escape") {
|
||||
// Stop it reaching useDismiss's document listener, which would close without refocusing.
|
||||
e.stopPropagation();
|
||||
close();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusIdx((i) => (i >= last ? 0 : i + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIdx((i) => (i <= 0 ? last : i - 1));
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setFocusIdx(0);
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setFocusIdx(last);
|
||||
} else if (e.key === "Tab") {
|
||||
// Tabbing out of a menu closes it, but the focus is moving on by itself.
|
||||
close({ refocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
ref={btnRef}
|
||||
onClick={() => (open ? close() : openMenu())}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
// The aria-label carries the mode's name at every size — the visible text below is a
|
||||
// nicety for wide windows, not the accessible name.
|
||||
aria-label={`${label}: ${current.label}`}
|
||||
@@ -73,39 +115,46 @@ export default function ViewSwitcher<T extends string>({
|
||||
className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* The modes are one exclusive choice, so the radio items need a group to scope their
|
||||
checked-ness to — otherwise they read as independent checkable items. */}
|
||||
<div role="group">
|
||||
{options.map((o) => {
|
||||
const Icon = o.icon;
|
||||
const on = o.id === value;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.id}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={o.label}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
||||
on ? "text-accent" : "text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
className="glass-menu absolute right-0 top-full mt-1 z-chrome min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* The modes are one exclusive choice, so the radio items need a group to scope their
|
||||
checked-ness to — otherwise they read as independent checkable items. */}
|
||||
<div role="group">
|
||||
{options.map((o, i) => {
|
||||
const Icon = o.icon;
|
||||
const on = o.id === value;
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
tabIndex={i === rovingIdx ? 0 : -1}
|
||||
onClick={() => {
|
||||
onChange(o.id);
|
||||
close();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
|
||||
on ? "text-accent" : "text-fg hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type SortState } from "../lib/columnSort";
|
||||
|
||||
@@ -85,145 +85,100 @@ function FilterOptionList({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// `autoFocus` masked it well enough that it read as "the filter is just twitchy".
|
||||
//
|
||||
// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel
|
||||
// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the
|
||||
// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go
|
||||
// back), the restore-focus effect that stopped Escape stranding focus on <body>, and the useDismiss
|
||||
// registration that had to include the trigger so its own mousedown didn't close-then-reopen.
|
||||
function FilterFunnel<T>({
|
||||
function FilterPopover<T>({
|
||||
col,
|
||||
value,
|
||||
onApply,
|
||||
open,
|
||||
onOpenChange,
|
||||
panelRef,
|
||||
}: {
|
||||
col: Column<T>;
|
||||
value: string | string[] | undefined;
|
||||
onApply: (key: string, value: string | string[]) => void;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
panelRef: RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const f = col.filter!;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// A text filter is a free-text field, so it is a dialog whose input takes focus; the option lists
|
||||
// are real menus with roving focus. `select` is one exclusive choice (radio), `multi` is several
|
||||
// independent ones (checkbox).
|
||||
const isText = f.kind === "text";
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode: isText ? "dialog" : "menu",
|
||||
role: isText ? "dialog" : "menu",
|
||||
});
|
||||
const filterOn = isActive(value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("datatable.filter")}
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={col.header}
|
||||
initialFocus={isText ? inputRef : undefined}
|
||||
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => onApply(col.key, e.target.value)}
|
||||
placeholder={col.header}
|
||||
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
)}
|
||||
{f.kind === "select" && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverItem
|
||||
role="menuitemradio"
|
||||
checked={!filterOn}
|
||||
onSelect={() => onApply(col.key, "")}
|
||||
label={t("datatable.all")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
|
||||
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{f.kind === "text" && (
|
||||
<input
|
||||
autoFocus
|
||||
value={(value as string) ?? ""}
|
||||
onChange={(e) => onApply(col.key, e.target.value)}
|
||||
placeholder={col.header}
|
||||
className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
)}
|
||||
{f.kind === "select" && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => onApply(col.key, "")}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{t("datatable.all")}
|
||||
</button>
|
||||
{f.options.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => onApply(col.key, o.value)}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{t("datatable.all")}
|
||||
</PopoverItem>
|
||||
{f.options.map((o) => (
|
||||
<PopoverItem
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.kind === "multi" && (
|
||||
<FilterOptionList>
|
||||
{f.options.length === 0 && (
|
||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||
)}
|
||||
{f.options.map((o) => {
|
||||
const arr = (value as string[]) ?? [];
|
||||
const on = arr.includes(o.value);
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
role="menuitemradio"
|
||||
checked={value === o.value}
|
||||
onSelect={() => onApply(col.key, o.value)}
|
||||
label={o.label}
|
||||
className={`text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:ring-1 data-[active]:ring-accent ${
|
||||
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||
onClick={() =>
|
||||
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||
}
|
||||
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.kind === "multi" && (
|
||||
<FilterOptionList>
|
||||
{f.options.length === 0 && (
|
||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||
)}
|
||||
{f.options.map((o) => {
|
||||
const arr = (value as string[]) ?? [];
|
||||
const on = arr.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="menuitemcheckbox"
|
||||
checked={on}
|
||||
onSelect={() =>
|
||||
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||
}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <X className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{o.label}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{filterOn && (
|
||||
<button
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
{on && <X className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{isActive(value) && (
|
||||
<button
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,14 +203,33 @@ export function ColumnHead<T>({
|
||||
filters: FilterMap;
|
||||
onFilter: (key: string, value: string | string[]) => void;
|
||||
}) {
|
||||
// Only one column's filter panel is open at a time; each funnel owns its own anchoring.
|
||||
const { t } = useTranslation();
|
||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
// The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own
|
||||
// mousedown closes the panel and the click that follows immediately reopens it — clicking the
|
||||
// funnel a second time then looks like it does nothing. Also the thing to hand focus back to.
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const restoreFocus = useRef(false);
|
||||
useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]);
|
||||
|
||||
// Escape (or an outside click) used to unmount the focused input and strand focus on <body>,
|
||||
// so the next Tab restarted from the top of the page.
|
||||
useEffect(() => {
|
||||
if (openFilter) {
|
||||
restoreFocus.current = true;
|
||||
} else if (restoreFocus.current) {
|
||||
restoreFocus.current = false;
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, [openFilter]);
|
||||
|
||||
return (
|
||||
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
||||
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
||||
{columns.map((col) => {
|
||||
const isSorted = sort?.key === col.key;
|
||||
const filterOn = isActive(filters[col.key]);
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
@@ -293,15 +267,33 @@ export function ColumnHead<T>({
|
||||
<span>{col.header}</span>
|
||||
)}
|
||||
{col.filter && (
|
||||
<FilterFunnel
|
||||
col={col}
|
||||
value={filters[col.key]}
|
||||
onApply={onFilter}
|
||||
open={openFilter === col.key}
|
||||
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
|
||||
/>
|
||||
<button
|
||||
// Captured at open time rather than bound with `ref`, so it survives the close
|
||||
// (a `ref` scoped to the open column nulls out before focus can go back).
|
||||
onClick={(e) => {
|
||||
triggerRef.current = e.currentTarget;
|
||||
setOpenFilter((o) => (o === col.key ? null : col.key));
|
||||
}}
|
||||
aria-label={t("datatable.filter")}
|
||||
aria-expanded={openFilter === col.key}
|
||||
aria-haspopup="dialog"
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{openFilter === col.key && col.filter && (
|
||||
<FilterPopover
|
||||
col={col}
|
||||
value={filters[col.key]}
|
||||
onApply={onFilter}
|
||||
panelRef={panelRef}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -46,11 +46,7 @@
|
||||
"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.)",
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
"create": "Create tag",
|
||||
"createPlaceholder": "New tag…",
|
||||
"createFailed": "Could not create the tag."
|
||||
"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.)"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
@@ -107,8 +103,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": "queued",
|
||||
"fullHistoryComing": "coming",
|
||||
"fullHistoryQueued": "full history queued",
|
||||
"fullHistoryComing": "full history 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.",
|
||||
|
||||
@@ -46,11 +46,7 @@
|
||||
"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.)",
|
||||
"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."
|
||||
"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.)"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"empty": "Nincsenek csatornák.",
|
||||
@@ -107,8 +103,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": "sorban",
|
||||
"fullHistoryComing": "úton",
|
||||
"fullHistoryQueued": "teljes előzmény sorban",
|
||||
"fullHistoryComing": "teljes előzmény ú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.",
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface ChannelDetail {
|
||||
backfill_done: boolean;
|
||||
from_explore: boolean;
|
||||
details_synced: boolean;
|
||||
tag_ids: number[];
|
||||
}
|
||||
|
||||
export interface ExploreResult {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface Tag {
|
||||
|
||||
export const tagsApi = {
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
|
||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
||||
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) }),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// The browser's Fullscreen API, WebKit included.
|
||||
//
|
||||
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
|
||||
@@ -49,16 +47,3 @@ export function onFullscreenChange(handler: () => void): () => void {
|
||||
document.removeEventListener("webkitfullscreenchange", handler);
|
||||
};
|
||||
}
|
||||
|
||||
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
|
||||
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
|
||||
const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
|
||||
const fsServerSnapshot = () => null;
|
||||
|
||||
/** The fullscreen element as reactive state, for code that must RENDER differently while something
|
||||
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
|
||||
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
|
||||
* merely react to the transition should keep using `onFullscreenChange` directly. */
|
||||
export function useFullscreenElement(): HTMLElement | null {
|
||||
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
|
||||
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
|
||||
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
|
||||
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
|
||||
// through this one stack — a layer acts on Escape only when it is topmost, and reads `anyAbove()` to
|
||||
// know whether to defer. Because the decision is a stack lookup, not event-propagation order, the fix
|
||||
// is robust to which listener happens to run first (the old stopPropagation was not).
|
||||
//
|
||||
// Notably this fixes the documented PlexPlayer bug: its menus register here, so the player sees a menu
|
||||
// as a layer ABOVE it and defers, letting the menu's own Escape close it first — the player's
|
||||
// auto-skip-cancel Escape then lands on the next press, instead of being swallowed by a blanket
|
||||
// stopPropagation. (Popover-vs-popover ordering is still floating-ui's <FloatingTree>; this stack adds
|
||||
// the cross-TYPE ordering the tree can't see.)
|
||||
|
||||
let stack: number[] = [];
|
||||
let nextId = 1;
|
||||
|
||||
export function pushLayer(): number {
|
||||
const id = nextId++;
|
||||
stack.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeLayer(id: number): void {
|
||||
stack = stack.filter((x) => x !== id);
|
||||
}
|
||||
|
||||
export function isTopmost(id: number): boolean {
|
||||
return stack.length > 0 && stack[stack.length - 1] === id;
|
||||
}
|
||||
|
||||
/** True when at least one layer sits above `id` — the "should I defer?" predicate for a layer with a
|
||||
* richer key handler than a plain close (the players). */
|
||||
export function anyLayerAbove(id: number): boolean {
|
||||
const i = stack.indexOf(id);
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
|
||||
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
|
||||
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
|
||||
* to decide whether to defer (the players). */
|
||||
export function useLayer(
|
||||
active: boolean,
|
||||
onEscape?: () => void,
|
||||
): { isTopmost: () => boolean; anyAbove: () => boolean } {
|
||||
const idRef = useRef<number | null>(null);
|
||||
const escRef = useRef(onEscape);
|
||||
escRef.current = onEscape;
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = pushLayer();
|
||||
idRef.current = id;
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
return () => {
|
||||
if (onKey) document.removeEventListener("keydown", onKey);
|
||||
removeLayer(id);
|
||||
idRef.current = null;
|
||||
};
|
||||
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
|
||||
}, [active]);
|
||||
|
||||
return {
|
||||
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
|
||||
anyAbove: () => idRef.current != null && anyLayerAbove(idRef.current),
|
||||
};
|
||||
}
|
||||
@@ -134,12 +134,6 @@ export function DetailCustomizeMenu({
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||||
// Deliberately NOT on the R8 popover platform. In the player's OVERLAY variant this menu renders
|
||||
// inside PlexPlayer's `transform: scale(1.25)` windowed subtree, and the platform portals its panel
|
||||
// out to <body> — which would paint it at 1× while the surrounding player UI is at 1.25×. A plain
|
||||
// `absolute` child stays in the scaled subtree and matches, in both this variant and the unscaled
|
||||
// detail-page variant. Same reason the player's own gear/tracks menus stay hand-rolled; migrating
|
||||
// any of them needs the platform to portal INTO a caller-named (scaled) container.
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
@@ -174,8 +168,6 @@ export function DetailCustomizeMenu({
|
||||
);
|
||||
}
|
||||
|
||||
/** One switch row inside `DetailCustomizeMenu`. Independent on/off toggles (all can be on at once),
|
||||
* so a plain button, not a menuitemradio. */
|
||||
export function PrefToggle({
|
||||
label,
|
||||
on,
|
||||
|
||||
@@ -14,25 +14,6 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.59.0",
|
||||
date: "2026-07-31",
|
||||
summary:
|
||||
"The menus, dropdowns and tooltips across the app are rebuilt on one system — with fuller keyboard and screen-reader support — and the player's Add to playlist and Download now work in fullscreen.",
|
||||
features: [
|
||||
"Player: Add to playlist and Download now work while the player is in fullscreen.",
|
||||
"Channel page: for a channel you follow, your tags are now shown and editable right in the header, next to Subscribe.",
|
||||
],
|
||||
fixes: [
|
||||
"Menus and dropdowns now support full keyboard navigation — arrow keys, Home/End and type-to-jump — and return focus to where you were when they close.",
|
||||
"Escape now closes only the menu or dialog on top, instead of also closing the player or window behind it.",
|
||||
"Tooltips and hover cards are steadier and no longer linger or get in the way; the header language switcher now opens on click.",
|
||||
"Channel tags stay on one line in the manager and reveal any that don't fit in a hover card, so the table no longer reflows as you add them.",
|
||||
],
|
||||
chores: [
|
||||
"Rebuilt the app's popovers, menus and tooltips on a single anchored-positioning system, replacing ~11 separate hand-written implementations.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.58.0",
|
||||
date: "2026-07-29",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { notify } from "./notifications";
|
||||
|
||||
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail
|
||||
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
|
||||
* optimistic update injected (the manager maps over a ManagedChannel[]; the detail patches one
|
||||
* ChannelDetail). Keeps the two call sites from drifting. */
|
||||
export function useChannelTagActions(opts: {
|
||||
/** Apply the optimistic tag change to whichever cache the caller owns. `attach` true = add. */
|
||||
optimisticToggle: (channelId: string, tagId: number, attach: boolean) => void;
|
||||
/** Called after the attach/detach lands (e.g. sync a sibling cache). Optional. */
|
||||
onSuccess?: (channelId: string) => void;
|
||||
/** Called if the attach/detach fails — revert by refetching the owning cache. */
|
||||
onError: (channelId: string) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
|
||||
opts.optimisticToggle(channelId, tagId, !hasTag);
|
||||
const call = hasTag
|
||||
? api.detachChannelTag(channelId, tagId)
|
||||
: api.attachChannelTag(channelId, tagId);
|
||||
call.then(() => opts.onSuccess?.(channelId)).catch(() => opts.onError(channelId));
|
||||
};
|
||||
|
||||
// Create a brand-new personal tag and attach it in one step (so tagging doesn't require the
|
||||
// top-of-page "Manage tags" dialog first). Refresh the tag catalog so it appears everywhere.
|
||||
const createAndAttachTag = (channelId: string, name: string) => {
|
||||
api
|
||||
.createTag({ name })
|
||||
.then((tag) => {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
toggleTag(channelId, tag.id, false);
|
||||
})
|
||||
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
|
||||
};
|
||||
|
||||
return { toggleTag, createAndAttachTag };
|
||||
}
|
||||
@@ -1,25 +1,14 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { useLayer } from "./layerStack";
|
||||
|
||||
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
|
||||
* element(s). The reusable half of the popover/dropdown pattern that several components each
|
||||
* hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref
|
||||
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close).
|
||||
*
|
||||
* Escape is dispatched through the shared `layerStack` (R8 S3): the popover registers as a layer and
|
||||
* closes only when it is topmost, so a Modal or another popover opened above it takes the Escape
|
||||
* first, and a player below defers to it. This replaces the old blanket `stopPropagation` — which
|
||||
* swallowed a player's own Escape (e.g. PlexPlayer's auto-skip-cancel) whenever any popover was open. */
|
||||
* (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). */
|
||||
export function useDismiss(
|
||||
active: boolean,
|
||||
onClose: () => void,
|
||||
refs: RefObject<HTMLElement | null> | RefObject<HTMLElement | null>[],
|
||||
): void {
|
||||
// Registration + topmost-only Escape.
|
||||
useLayer(active, onClose);
|
||||
|
||||
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
|
||||
// that lands outside every registered element.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const list = Array.isArray(refs) ? refs : [refs];
|
||||
@@ -27,12 +16,29 @@ export function useDismiss(
|
||||
const target = e.target as Node;
|
||||
if (!list.some((r) => r.current?.contains(target))) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
// Stop the same Escape from also reaching a player's window-level listener underneath —
|
||||
// this document listener bubbles first, so one Escape closes only this popover, not the
|
||||
// popover AND the player behind it. (U-C quick-fix.)
|
||||
//
|
||||
// KNOWN LIMITATION (accepted, → R8 popover-platform): because this is a blanket
|
||||
// stopPropagation, a PlexPlayer settings/tracks menu open DURING an auto-skip countdown
|
||||
// swallows the Escape that would otherwise ALSO cancel the skip — Escape closes the menu,
|
||||
// and the skip proceeds. Properly fixing this needs the shared layer-registry R8 builds
|
||||
// (topmost-only dispatch), not a broad stopPropagation.
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
// Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
|
||||
// onClose is read fresh from this run's closure.
|
||||
// Matches the original effects: (re)subscribe only when open/closed toggles. Refs are
|
||||
// stable and onClose is read fresh from this run's closure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ export interface YTPlayer {
|
||||
getDuration(): number;
|
||||
getVideoData(): YTVideoData;
|
||||
getIframe(): HTMLIFrameElement;
|
||||
// Current latched playback quality (e.g. "hd1080") — used to detect when to drop the HD-latch
|
||||
// iframe from its scaled-up size to native sizing. `setPlaybackQuality` is deliberately NOT typed:
|
||||
// YouTube retired it (a hard no-op) — see [[reference-yt-iframe-api-remote-control-map]].
|
||||
getPlaybackQuality(): string;
|
||||
setSize(width: number, height: number): void;
|
||||
getVolume(): number;
|
||||
isMuted(): boolean;
|
||||
setVolume(volume: number): void;
|
||||
|
||||
+20
-28
@@ -1,7 +1,6 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import { NavigationProvider } from "./components/NavigationProvider";
|
||||
@@ -44,36 +43,29 @@ const root =
|
||||
) : path === "/terms" ? (
|
||||
<Terms />
|
||||
) : path.startsWith("/watch/") ? (
|
||||
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
|
||||
<FloatingTree>
|
||||
<WatchPage />
|
||||
</FloatingTree>
|
||||
<WatchPage />
|
||||
) : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||
<FloatingTree>
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
</FloatingTree>
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user