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