feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)

- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
This commit is contained in:
2026-09-26 01:23:44 +02:00
parent 9d7526ee57
commit 75ae810866
26 changed files with 1189 additions and 557 deletions
+9
View File
@@ -270,6 +270,14 @@ Which actions are visible and which live in a menu.
A playlist item's X column goes: Delete key plus an undo toast.
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
header action ("Clear everything", "Purge discovery") goes into the header's "…".
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
press closes it). An object's list is one builder (`useVideoActions` for a video, composed by the feed
and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
the title (`.touch-only`).
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
@@ -403,5 +411,6 @@ Wherever there is text, the user can select it and copy it, and no click action
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
+62 -25
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -23,24 +23,78 @@ export default function AddToPlaylist({
size = "sm",
}: {
videoId: string;
variant?: "ghost" | "outline" | "overlay";
variant?: "ghost" | "outline" | "overlay" | "media";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useMembershipPopover(open, setOpen);
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...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();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<MembershipPanel videoId={videoId} pop={pop} />
</>
);
}
const pop = useAnchoredPopover({
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
anchor,
onClose,
}: {
videoId: string;
anchor: Element;
onClose: () => void;
}) {
const pop = useMembershipPopover(true, (next) => {
if (!next) onClose();
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
return <MembershipPanel videoId={videoId} pop={pop} />;
}
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
return useAnchoredPopover({
open,
onOpenChange: setOpen,
onOpenChange,
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,
});
}
function MembershipPanel({
videoId,
pop,
}: {
videoId: string;
pop: ReturnType<typeof useAnchoredPopover>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
@@ -95,23 +149,6 @@ export default function AddToPlaylist({
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...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();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* 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. */}
+2 -66
View File
@@ -1,10 +1,7 @@
import { useTranslation } from "react-i18next";
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { type PlexCard } from "../lib/api";
import { pinPlex, useComfortPin, type PinTarget } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
@@ -46,64 +43,3 @@ export function ComfortPinMenu<T>({
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
export function FeedVideoMenu({
menu,
onState,
onToggleSave,
onResetState,
}: {
menu: ContextMenuController<Video>;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const v = menu.target;
const icon = "w-4 h-4";
const items: ActionMenuItem[] = v
? [
v.status === "watched"
? {
id: "watched",
label: t("card.menu.unmarkWatched"),
icon: <Undo2 className={icon} />,
onSelect: () => onState(v.id, "new"),
}
: {
id: "watched",
label: t("card.markWatched"),
icon: <Check className={icon} />,
onSelect: () => onState(v.id, "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
{
id: "hide",
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
},
...(isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...pin.item(pinVideo(v.id), v.title ?? ""),
]
: [];
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
}
@@ -1,134 +0,0 @@
// @vitest-environment jsdom
//
// The feed card's right-click menu (prod UAT 2026-09-19): it carried only the comfort-shelf pin, so
// the card's own state actions — watched, saved, hidden, reset — were reachable only by hovering or
// by Tabbing through every card. Each row fires the same handler the card's button does.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../lib/api";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }) } };
});
import { FeedVideoMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(cleanup);
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found;
};
const video = (extra: Partial<Video>): Video =>
({
id: "v1",
title: "A video",
status: "new",
saved: false,
position_seconds: 0,
...extra,
}) as Video;
type Handlers = {
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
};
function Feed({ v, handlers }: { v: Video; handlers: Handlers }) {
const menu = useContextMenu<Video>();
return (
<>
<div {...menu.triggerProps(v)}>card</div>
<FeedVideoMenu
menu={menu}
onState={handlers.onState}
onToggleSave={handlers.onToggleSave}
onResetState={handlers.onResetState}
/>
</>
);
}
function openMenu(v: Video) {
const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() };
render(
<QueryClientProvider client={new QueryClient()}>
<FloatingTree>
<Feed v={v} handlers={handlers} />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.contextMenu(screen.getByText("card"));
return { handlers };
}
describe("the feed card's menu", () => {
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
const { handlers } = openMenu(video({}));
expect(labels()).toEqual([
"card.markWatched",
"card.saveForLater",
"card.hide",
"home.pin.add",
]);
fireEvent.click(item("card.markWatched"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
});
it("offers the way back on a watched, saved one — and the reset", async () => {
const { handlers } = openMenu(video({ status: "watched", saved: true }));
expect(labels()).toEqual([
"card.menu.unmarkWatched",
"card.menu.unsave",
"card.hide",
"card.resetState",
"home.pin.add",
]);
fireEvent.click(item("card.menu.unsave"));
await vi.waitFor(() => expect(handlers.onToggleSave).toHaveBeenLastCalledWith("v1", false));
});
it("unhides a hidden one", async () => {
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
fireEvent.click(item("card.unhide"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
});
it("clears the progress of a half-watched one", async () => {
const { handlers } = openMenu(video({ position_seconds: 30 }));
fireEvent.click(item("card.resetState"));
await vi.waitFor(() => expect(handlers.onResetState).toHaveBeenLastCalledWith("v1"));
});
});
+13
View File
@@ -38,6 +38,7 @@ import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import { useVideoActions } from "./useVideoActions";
import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
@@ -122,6 +123,9 @@ function Rail({
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
@@ -137,6 +141,11 @@ function Rail({
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
@@ -170,6 +179,7 @@ function Rail({
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
const menuFor = useItemTriggers(cards, menu.triggerProps);
const buttonFor = useItemTriggers(cards, menu.buttonProps);
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
@@ -204,6 +214,8 @@ function Rail({
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : c.plex ? (
<PlexPosterCard
@@ -221,6 +233,7 @@ function Rail({
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{videoActions.panel}
</section>
);
}
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// E28 C1: the card shows its state and the everyday toggles (watched, save, add to playlist) and a
// "…" into the list's ONE menu; download, details, hide and reset live only in that menu. Every view
// carries the same set, and a view that shows the "…" twice (the card: in the hover row on the
// picture, and beside the title for touch — only one is displayed per device) points both at the
// same menu.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ openChannel: vi.fn() }),
}));
import VideoCard from "./VideoCard";
import type { Video } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(cleanup);
const video = {
id: "abc123",
title: "Cicada 3301",
channel_id: "c1",
channel_title: "LEMMiNO",
status: "new",
saved: false,
position_seconds: 40,
duration_seconds: 1200,
published_at: "2026-01-01T00:00:00Z",
watch_url: "https://www.youtube.com/watch?v=abc123",
thumbnail_url: "",
} as unknown as Video;
function renderCard(view: FeedView, menuOpen = false) {
const buttonProps = vi.fn(() => ({ "aria-haspopup": "menu" }));
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view={view}
width={0}
onState={() => {}}
onToggleSave={() => {}}
onOpen={() => {}}
menuButtonProps={buttonProps}
menuOpen={menuOpen}
/>
</QueryClientProvider>,
);
return { buttonProps };
}
const names = () => screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"));
describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions", (view) => {
it("are the toggles and the '…' — nothing that belongs to the menu", () => {
renderCard(view);
const all = names();
for (const want of ["card.markWatched", "card.saveForLater", "playlists.addToPlaylist"])
expect(all).toContain(want);
expect(all).toContain("card.menu.more");
for (const gone of ["downloads.button.label", "card.hide", "card.resetState", "card.info"])
expect(all).not.toContain(gone);
});
it("points every '…' at the menu, and says when it is open", () => {
const { buttonProps } = renderCard(view, true);
const mores = screen.getAllByRole("button", { name: "card.menu.more" });
expect(buttonProps).toHaveBeenCalledWith(video);
for (const m of mores) {
expect(m.getAttribute("aria-haspopup")).toBe("menu");
expect(m.getAttribute("aria-expanded")).toBe("true");
}
});
});
@@ -98,3 +98,26 @@ describe("VideoCard — the thumbnail link", () => {
expect(screen.queryByRole("link", { name: "20:00" })).toBeNull();
});
});
// E28 C1 "Text stays selectable": the mouseup that ends selecting the title's words fires a click on
// the title; that click must not open the player (nor follow the link).
describe("VideoCard — a title you select", () => {
it("does not open when the click ends a selection inside it, and still opens on a plain click", () => {
const onOpen = renderCard("cards");
const title = screen.getByTestId("video-card").querySelector("[data-nav-open]") as HTMLElement;
// Both, or Chrome starts no selection on a link (measured in the S3e1 UAT).
expect(title.getAttribute("draggable")).toBe("false");
expect(title.classList.contains("select-text")).toBe(true);
const range = document.createRange();
range.selectNodeContents(title);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
const click = new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 });
title.dispatchEvent(click);
expect(onOpen).not.toHaveBeenCalled();
expect(click.defaultPrevented).toBe(true);
window.getSelection()!.removeAllRanges();
title.click();
expect(onOpen).toHaveBeenCalledWith(video);
});
});
+219 -196
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "./navigationContext";
import {
@@ -6,8 +6,6 @@ import {
Check,
CheckCheck,
Clock,
Eye,
EyeOff,
Play,
Radio,
RotateCcw,
@@ -16,101 +14,95 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import { endsTextSelection } from "../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
// E28 C1: the card keeps its state and the everyday toggles — watched, save, add to playlist — and
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
function Actions({
video,
onState,
onResetState,
onToggleSave,
more,
media = false,
dense = false,
}: {
video: Video;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
more?: More;
/** On the thumbnail: the round, dark `media` face (A2). */
media?: boolean;
/** Tighter gap for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: VideoStatus) => (e: React.MouseEvent) => {
const toggleWatched = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
onState(video.id, video.status === "watched" ? "new" : "watched");
};
// The reset clears the whole state (incl. an in-progress position the un-watch toggle can't touch).
const resettable = isResettable(video);
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// Every button of the row is the IconButton `sm` rung (28), AddToPlaylist and DownloadButton
// included. `dense` halves the gap for the small card, whose text column bottoms out around 160px;
// six 28px buttons wrap there (the flex-wrap below) until the C1 action list (E28 S3e) moves the
// rare ones into the card's "…".
const variant = media ? "media" : "ghost";
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
// one-line row's w-32 column (ROW_COL.actions).
return (
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap reveal transition", dense ? "gap-0.5" : "gap-1")}>
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton
onClick={act("watched")}
variant={variant}
onClick={toggleWatched}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className="reveal-hover"
>
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
variant={variant}
onClick={toggleSave}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className="reveal-hover"
>
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<IconButton
onClick={act("hidden")}
pressed={video.status === "hidden"}
label={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
>
{video.status === "hidden" ? <Eye /> : <EyeOff />}
</IconButton>
{onResetState && resettable && (
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
label={t("card.resetState")}
>
<RotateCcw />
</IconButton>
<span className="reveal-hover flex">
<AddToPlaylist videoId={video.id} variant={variant} />
</span>
{more && (
<MoreButton
variant={variant}
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
);
}
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-base");
// The thumbnail's hover chrome: shown on hover or keyboard focus on a mouse, removed on touch.
const HOVER_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity";
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
@@ -121,6 +113,9 @@ function openInApp(
): void {
if (!onOpen || !isPlainLeftClick(e)) return;
e.preventDefault();
// The click that ends selecting the title's words (to copy them) neither plays nor follows the
// link (docs/DESIGN.md "Text stays selectable").
if (endsTextSelection(e.currentTarget)) return;
onOpen(video);
}
@@ -203,6 +198,7 @@ function Thumb({
className,
onOpen,
small = false,
bar,
}: {
video: Video;
className?: string;
@@ -210,6 +206,10 @@ function Thumb({
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
/** The card's hover action row, docked on the picture's bottom-right (the C1 reference). A
* SIBLING of the link, not inside it: a click in the playlists panel it opens would otherwise
* bubble (through the portal, along the React tree) into the link and start playback. */
bar?: ReactNode;
}) {
const { t } = useTranslation();
const inProgress = isInProgress(video);
@@ -223,130 +223,134 @@ function Thumb({
};
return (
// Named by the title: its only text is the duration badge, so a screen reader heard the first Tab
// stop of every card as "44:10" (E28 S3d4).
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className,
)}
>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null ? (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm">
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
<div className={clsx("relative", className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10" (E28 S3d4). */}
<a
href={video.watch_url}
target="_blank"
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className="block relative h-full w-full rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow"
>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null ? (
<span
className={clsx(
"absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm",
// The hover row takes this corner; the duration steps aside while it shows.
bar && "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0",
)}
>
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
{onOpen && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
{inProgress ? (
<>
{small ? (
<IconButton
variant="primary"
size="md"
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
{onOpen && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
{inProgress ? (
<>
{small ? (
<IconButton
variant="primary"
size="md"
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</a>
{bar && (
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
)}
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} round className={INFO_OVERLAY} />
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</a>
</div>
);
}
@@ -358,7 +362,7 @@ function Thumb({
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
@@ -394,10 +398,11 @@ function VideoCard({
view,
width,
onState,
onResetState,
onToggleSave,
onOpen,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
video: Video;
@@ -405,15 +410,19 @@ function VideoCard({
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
/** Right-click / long-press props for the LIST's one context menu (E24 S3 — the comfort pin).
/** Right-click / long-press props for the LIST's one context menu (E28 C1: the video's actions).
* A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across
* renders while its return value is a fresh object every call, and a fresh object prop would
* blow past this component's memo for every card in the virtualized feed on every parent
* render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>;
/** The same menu's "…" button props (`useContextMenu.buttonProps`) — a function, for the same memo
* reason. Omitted, the card has no "…" (the menu is still a right-click away). */
menuButtonProps?: (v: Video) => Record<string, unknown>;
/** This card's menu is the open one (the "…" button's `aria-expanded`). */
menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
}) {
@@ -478,13 +487,16 @@ function VideoCard({
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
// A link is dragged, not selected: Chrome starts no selection on one even with the drag off,
// until it is also `user-select: text` (select-text below) — measured in the S3e1 UAT.
draggable={false}
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
// click: the in-app player, resuming where the reader left off.
data-nav-open=""
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
// min-h-6: a one-line title is still a 24 target (B1).
"min-h-6 font-medium leading-snug hover:text-accent",
"min-h-6 select-text font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2",
)}
>
@@ -509,14 +521,19 @@ function VideoCard({
className="text-sm leading-6 text-muted truncate block w-fit max-w-full text-left hover:text-fg"
/>
);
const more = menuButtonProps && { props: menuButtonProps(video), open: menuOpen };
// The rows' action block: shown on hover / focus on a mouse (`.reveal`), always on touch — where
// the toggles inside it are gone and only the "…" is left.
const actions = (
<Actions
video={video}
onState={onState}
onResetState={onResetState}
onToggleSave={onToggleSave}
dense={spec.dense}
/>
<div className="reveal">
<Actions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
dense={spec.dense}
/>
</div>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
@@ -556,13 +573,8 @@ function VideoCard({
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. This row has no thumbnail to host the info
trigger, so it sits inline right after the actions (still outside their six-button
budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_REVEAL} />
</div>
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
<div className="shrink-0 w-32">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a
@@ -639,10 +651,8 @@ function VideoCard({
);
}
// The flex column is what puts the actions on the card's bottom edge: the grid already stretches
// every card in a row to the tallest one (align-items: stretch), so the column just needs to push
// its last child down. Otherwise the action row floats under a 1-line title on one card and a
// line lower on its 2-line neighbour — the same control landing somewhere different on each.
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
// of its own), and on touch its "…" sits beside the title as a 44px target.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering.
@@ -662,7 +672,15 @@ function VideoCard({
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
and would spill straight out of it. */}
<Thumb video={video} className="aspect-video" onOpen={onOpen} small={spec.dense} />
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
}
/>
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
gap would leave the title barely half the width. The channel name is still below it. */}
@@ -673,10 +691,15 @@ function VideoCard({
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
<div className="mt-auto pt-1">{actions}</div>
</div>
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<MoreButton
className="touch-only -mr-1 shrink-0"
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
</div>
);
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense } from "react";
import { type Video } from "../lib/api";
import { AddToPlaylistAt } from "./AddToPlaylist";
import VideoDetailsAt from "./VideoInfoCard";
// Lazy for the same reason DownloadButton's is: the dialog and its profile editor load only when
// someone downloads, not with every feed.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
/** What a video menu row opened that outlives the menu: a panel anchored where the menu came from
* (the playlists, the details) or the download dialog. */
export type VideoFollowUpState =
| { kind: "playlist" | "details"; video: Video; anchor: Element }
| { kind: "download"; video: Video };
/** The one follow-up a video list has open, mounted once per list beside its menu (see
* `useVideoActions`) rather than once per card. */
export function VideoFollowUp({
state,
onClose,
}: {
state: VideoFollowUpState;
onClose: () => void;
}) {
const { video } = state;
if (state.kind === "download")
return (
<Suspense fallback={null}>
<DownloadDialog source={video.id} title={video.title} onClose={onClose} />
</Suspense>
);
if (state.kind === "playlist")
return <AddToPlaylistAt videoId={video.id} anchor={state.anchor} onClose={onClose} />;
return <VideoDetailsAt video={video} anchor={state.anchor} onClose={onClose} />;
}
@@ -1,13 +1,12 @@
// @vitest-environment jsdom
//
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
// Contract of a video's details panel (E8·S3; opened from the video's menu since E28 C1): the
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui, so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -30,7 +29,7 @@ vi.mock("../lib/api", () => ({
},
}));
import VideoInfoCard from "./VideoInfoCard";
import VideoDetailsAt from "./VideoInfoCard";
import type { Video } from "../lib/api";
beforeAll(() => {
@@ -72,33 +71,31 @@ const video = (over: Partial<Video> = {}): Video =>
...over,
}) as Video;
const open = (v: Video = video()) => {
const open = (v: Video = video(), onClose = vi.fn()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const anchor = document.createElement("div");
document.body.appendChild(anchor);
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={v} className="info-trigger" />
<VideoDetailsAt video={v} anchor={anchor} onClose={onClose} />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
return { onClose };
};
describe("VideoInfoCard", () => {
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={video()} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
expect(screen.queryByText("The full untruncated title")).toBeNull();
// `enabled: open` gates the queries — nothing fired while closed.
expect(videoDetail).not.toHaveBeenCalled();
expect(channelDetail).not.toHaveBeenCalled();
describe("VideoDetailsAt", () => {
// Escape is measured in the browser: jsdom has no layout, so the anchor reads as hidden and the
// layer stack never takes the panel's Escape.
it("is a named dialog", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
await screen.findByText("The full untruncated title");
// Named by its aria-label (the role query cannot see into the unpositioned, hidden surface).
expect(document.querySelector("[role=dialog]")?.getAttribute("aria-label")).toBe("card.info");
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
+51 -82
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { useLayoutEffect } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { Info } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type Video } from "../lib/api";
import { formatDate, formatDuration, formatViews } from "../lib/format";
@@ -9,17 +8,14 @@ import { linkify } from "../lib/linkify";
import Avatar from "./ui/Avatar";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
// reachable by Tab.
// A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a
// description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into
// the video's action list ("Details"): the hover card was invisible on touch and one more button in
// the card's hover row, and the menu is the one door every device has. It opens anchored to the card
// the menu came from, as a small dialog.
//
// The card face already carries title/channel/date/duration/views, so the box's real added value is
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
@@ -27,54 +23,44 @@ import { Badge } from "./ui/Badge";
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
// channel page's cache) against the tag list. Until they land the header still shows from the Video
// we already have, so the box is never empty.
//
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
// so the trigger deliberately lives OUTSIDE it.
export default function VideoInfoCard({
export default function VideoDetailsAt({
video,
round = false,
className,
anchor,
onClose,
}: {
video: Video;
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
round?: boolean;
/** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
* knows its surface (overlay vs inline); the skin is the IconButton's. */
className?: string;
anchor: Element;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "hover",
placement: "bottom-end",
open: true,
onOpenChange: (next) => {
if (!next) onClose();
},
mode: "dialog",
role: "dialog",
placement: "bottom-start",
offset: 6,
// A touch of open intent (so brushing past the button while reaching another control doesn't
// pop it), a longer close grace so the cursor can travel into the interactive card.
hoverDelay: { open: 140, close: 160 },
fitHeight: true,
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
const { data: detail } = useQuery({
queryKey: qk.videoDetail(video.id),
queryFn: () => api.videoDetail(video.id),
enabled: open,
staleTime: 5 * 60_000,
});
const { data: channel } = useQuery({
queryKey: qk.channel(video.channel_id),
queryFn: () => api.channelDetail(video.channel_id),
enabled: open && !!video.channel_id,
enabled: !!video.channel_id,
staleTime: 5 * 60_000,
});
const { data: tags } = useQuery({
queryKey: qk.tags(),
queryFn: () => api.tags(),
enabled: open,
staleTime: 5 * 60_000,
});
@@ -95,53 +81,36 @@ export default function VideoInfoCard({
].filter(Boolean);
return (
<>
<IconButton
variant={round ? "media" : "ghost"}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
label={t("card.info")}
className={className}
>
<Info />
</IconButton>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
<PopoverSurface
popover={pop}
ariaLabel={t("card.info")}
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
>
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
<div className="mt-2 flex items-center gap-2">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-6 h-6 rounded-full shrink-0"
/>
<span className="text-xs text-muted truncate">{video.channel_title}</span>
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<Badge key={tg.id}>{tg.name}</Badge>
))}
</div>
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
{description ? (
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
{linkify(description)}
</p>
) : detail ? (
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
) : null}
{tagNames.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{tagNames.map((tg) => (
<Badge key={tg.id}>{tg.name}</Badge>
))}
</div>
)}
</PopoverSurface>
</>
)}
</PopoverSurface>
);
}
@@ -0,0 +1,209 @@
// @vitest-environment jsdom
//
// E28 C1: a video's ONE action list. The right-click and the card's "…" open the same menu, which
// holds everything the card no longer shows (download, details, copy, hide, reset, pin) beside the
// everyday toggles, and each row fires the same handler the card's own buttons do.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Video } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: {
...actual.api,
comfortPins: () => Promise.resolve({ items: [] }),
downloadIndex: () => Promise.resolve({}),
playlists: () => Promise.resolve([]),
},
};
});
const copyText = vi.fn((_: string) => Promise.resolve(true));
vi.mock("../lib/clipboard", () => ({ copyText: (s: string) => copyText(s) }));
import { VideoMenu } from "./VideoMenu";
import { MoreButton } from "./ui/MoreButton";
import { useContextMenu } from "./ui/useContextMenu";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
const item = (label: string) => {
const found = screen
.queryAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
return found;
};
const video = (extra: Partial<Video>): Video =>
({
id: "v1",
title: "A video",
channel_id: "c1",
channel_title: "A channel",
watch_url: "https://youtu.be/v1",
status: "new",
saved: false,
position_seconds: 0,
...extra,
}) as Video;
function Feed({
v,
handlers,
}: {
v: Video;
handlers: Parameters<typeof VideoMenu>[0]["handlers"];
}) {
const menu = useContextMenu<Video>();
return (
<>
<div {...menu.triggerProps(v)}>card</div>
<MoreButton
label="more"
expanded={menu.open && menu.target?.id === v.id}
menuProps={menu.buttonProps(v)}
/>
<VideoMenu menu={menu} handlers={handlers} />
</>
);
}
function setup(v: Video, { demo = false } = {}) {
const handlers = {
onState: vi.fn(),
onToggleSave: vi.fn(),
onResetState: vi.fn(),
onOpen: vi.fn(),
};
const qc = new QueryClient();
qc.setQueryData(qk.me(), { is_demo: demo });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<Feed v={v} handlers={handlers} />
</FloatingTree>
</QueryClientProvider>,
);
return { handlers };
}
const rightClick = () => fireEvent.contextMenu(screen.getByText("card"));
const more = () => screen.getByRole("button", { name: "more" });
describe("a video's menu", () => {
it("lists the whole action list for a fresh video, in its groups", () => {
setup(video({}));
rightClick();
expect(labels()).toEqual([
"card.play",
"card.menu.openChannel",
"card.markWatched",
"card.saveForLater",
"playlists.addToPlaylist",
"downloads.button.label",
"card.info",
"common.copy.title",
"common.copy.link",
"card.hide",
"home.pin.add",
]);
});
it("offers Continue and the restart on a half-watched one, the way back and the reset", async () => {
const { handlers } = setup(video({ position_seconds: 30, saved: true }));
rightClick();
expect(labels()).toContain("card.restartTitle");
expect(labels()).toContain("card.menu.unsave");
expect(labels()).toContain("card.resetState");
fireEvent.click(item("card.restartTitle"));
await vi.waitFor(() => expect(handlers.onOpen).toHaveBeenLastCalledWith(expect.anything(), 0));
});
it("each toggle fires the card's own handler", async () => {
const { handlers } = setup(video({ status: "watched" }));
rightClick();
fireEvent.click(item("card.menu.unmarkWatched"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
rightClick();
fireEvent.click(item("card.hide"));
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "hidden"));
});
it("copies the link INSIDE the click, before the menu closes (Safari's gesture rule)", () => {
setup(video({}));
rightClick();
fireEvent.click(item("common.copy.link"));
// Synchronously: a row that went through closeThenAct would run a task later.
expect(copyText).toHaveBeenCalledWith("https://youtu.be/v1");
expect(labels()).toEqual([]);
});
it("opens the playlists panel from the menu, anchored where the menu came from", async () => {
setup(video({}));
rightClick();
fireEvent.click(item("playlists.addToPlaylist"));
expect(await screen.findByText("playlists.noneYet")).toBeTruthy();
});
it("has no download row on the demo account", () => {
setup(video({}), { demo: true });
rightClick();
expect(labels()).not.toContain("downloads.button.label");
});
it("the '…' opens the same menu, says so, and a second press closes it", () => {
setup(video({}));
expect(more().getAttribute("aria-haspopup")).toBe("menu");
expect(more().getAttribute("aria-expanded")).toBe("false");
fireEvent.click(more());
expect(labels()).toContain("card.hide");
expect(more().getAttribute("aria-expanded")).toBe("true");
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
fireEvent.pointerDown(more());
fireEvent.mouseDown(more());
fireEvent.click(more(), { detail: 1 });
expect(labels()).toEqual([]);
});
it("a keyboard click on the '…' always opens, whatever an earlier press left behind", () => {
setup(video({}));
fireEvent.click(more(), { detail: 1 });
// A press on the open row's "…" that never became a click (released elsewhere)…
fireEvent.pointerDown(more());
fireEvent.keyDown(document, { key: "Escape" });
// …then Enter on the button: a click with no pointer (detail 0).
fireEvent.click(more(), { detail: 0 });
expect(labels()).toContain("card.hide");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
import { type Video } from "../lib/api";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
import { useVideoActions, type VideoActionHandlers } from "./useVideoActions";
/** A video list's one menu (the feed grid and a channel page): every trigger — right-click,
* long-press, Shift+F10 and each card's "…" — opens it with the video's whole action list. Render
* ONE per list, outside the cards (see `ui/ContextMenu`). */
export function VideoMenu({
menu,
handlers,
}: {
menu: ContextMenuController<Video>;
handlers: VideoActionHandlers;
}) {
const { t } = useTranslation();
const actions = useVideoActions(handlers, menu.open);
const v = menu.target;
return (
<>
<ContextMenu
menu={menu}
ariaLabel={t("card.menu.label")}
items={v ? actions.items(v, menu.anchor) : []}
/>
{actions.panel}
</>
);
}
+19 -21
View File
@@ -1,7 +1,7 @@
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
import { FeedVideoMenu } from "./ComfortPinMenu";
import { VideoMenu } from "./VideoMenu";
import { useContextMenu } from "./ui/useContextMenu";
import VirtualGrid from "./ui/VirtualGrid";
@@ -9,15 +9,15 @@ import VirtualGrid from "./ui/VirtualGrid";
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
// when the bare row became a single line and halved (82 -> 46). The card figures are the roughest
// of the set by nature — a card's height follows its column width (measured ~307 at 3 columns,
// ~301 at 4, taller still at 2), so no single constant is right for every window.
// when the E28 C1 action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226).
// The card figures are the roughest of the set by nature — a card's height follows its column width
// (262 measured at 4 columns in a 1268px window), so no single constant is right for every window.
const ROW_EST: Record<FeedView, number> = {
cards: 340,
cardsSmall: 257,
rows: 115,
cards: 262,
cardsSmall: 226,
rows: 117,
rowsCompact: 46,
tiles: 132,
tiles: 138,
};
export interface VirtualFeedProps {
@@ -47,11 +47,12 @@ export default function VirtualFeed({
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
// The card menu (its state actions and the comfort-shelf pin, E24 S3) lives HERE rather than in each
// of the feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
// the same component all the way down.
const pinMenu = useContextMenu<Video>();
// The card menu (the video's whole action list, E28 C1) lives HERE rather than in each of the
// feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting
// it at this level is also what gives the channel page the menu for free — it renders
// `<Feed channelScoped>`, so it is the same component all the way down.
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
return (
<>
<VirtualGrid
@@ -76,18 +77,15 @@ export default function VirtualFeed({
width={thumb ? 0 : itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={onOpen}
menuProps={pinMenu.triggerProps}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
// A boolean per card, not the open id: only the two cards whose answer changes re-render.
menuOpen={openId === v.id}
/>
)}
/>
<FeedVideoMenu
menu={pinMenu}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
/>
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen }} />
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { type ActionMenuItem } from "./ui/ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with (E28 C1,
// "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a
// finger cannot select is copied from here. One builder, so every object says it the same way and
// the clipboard write always runs inside the gesture (`inGesture`, which Safari insists on).
//
// A feature file, not `ui/`: it speaks the app's notifications.
type Named = "title" | "name";
export function useCopyItems() {
const { t } = useTranslation();
const copy = (text: string, done: string) => {
void copyText(text).then((ok) =>
notify(
ok
? { level: "success", message: done }
: { level: "error", message: t("common.copy.failed") },
),
);
};
/** The rows for one object. `text` is its title (a video, a show) or its name (a channel, a
* playlist); a row whose value is missing is left out rather than copying an empty string. */
return (
text: string | null | undefined,
link: string | null | undefined,
named: Named = "title",
): ActionMenuItem[] => [
...(text
? [
{
id: "copy-text",
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(text, t("common.copy.done")),
},
]
: []),
...(link
? [
{
id: "copy-link",
label: t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />,
inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")),
},
]
: []),
];
}
+9 -1
View File
@@ -58,6 +58,10 @@ interface ActionItem extends MenuItemBase {
onSelect: () => void;
/** Destructive action: painted in the danger colour, like the outline-danger buttons. */
danger?: boolean;
/** Run INSIDE the click, then close — for a clipboard write, which Safari allows only within the
* user's gesture (a task later, as `closeThenAct` runs every other row, it is refused). Only for
* an action that opens nothing and moves no focus: that is what the deferral exists for. */
inGesture?: boolean;
items?: never;
}
@@ -100,7 +104,11 @@ export function MenuItemList({
label={it.label}
title={it.title}
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
onSelect={() => closeThenAct(onClose, it.onSelect)}
onSelect={() => {
if (!it.inGesture) return closeThenAct(onClose, it.onSelect);
it.onSelect();
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`}
>
{it.icon && (
+37
View File
@@ -0,0 +1,37 @@
import { MoreHorizontal } from "lucide-react";
import { IconButton } from "./IconButton";
import { type IconButtonSize, type IconButtonVariant } from "./buttonStyles";
// The visible door to an object's ONE action list (docs/DESIGN.md "Action placement", C1): the "…"
// that opens the same menu as a right-click, a long-press and Shift+F10, so no action is reachable
// only by a gesture. It carries no menu of its own — `menuProps` is the list's
// `useContextMenu().buttonProps(row)`, and `expanded` says whether THIS row's menu is the open one.
export function MoreButton({
label,
expanded,
menuProps,
variant = "ghost",
size = "sm",
className,
}: {
/** The menu's name ("Video actions"): the button's accessible name and tooltip. */
label: string;
expanded: boolean;
menuProps: Record<string, unknown>;
variant?: IconButtonVariant;
size?: IconButtonSize;
className?: string;
}) {
return (
<IconButton
variant={variant}
size={size}
label={label}
aria-expanded={expanded}
className={className}
{...menuProps}
>
<MoreHorizontal />
</IconButton>
);
}
+4 -4
View File
@@ -36,10 +36,10 @@ domain-free.
## What lives here
| area | components |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
| area | components |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `Input` + `fieldStyles`, `NativeControls`, `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the |
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
+67 -3
View File
@@ -61,8 +61,15 @@ export interface ContextMenuController<T> {
target: T | null;
open: boolean;
close: () => void;
/** The element the open menu came from — the row, or its "…" button. A follow-up an item opens
* (a panel anchored beside the object) anchors here. `null` while closed. */
anchor: Element | null;
/** Spread on the element that should answer a right-click — the whole row, not a handle. */
triggerProps: (payload: T) => Record<string, unknown>;
/** Spread on the row's visible "…" button (E28 C1: nothing is reachable only by a gesture). A
* click opens the SAME menu under the button; a second click on it while its menu is open closes
* it, like any menu button. Pair it with `aria-expanded` from the caller, which knows its row. */
buttonProps: (payload: T) => Record<string, unknown>;
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
popover: ReturnType<typeof useAnchoredPopover>;
}
@@ -71,13 +78,27 @@ export function useContextMenu<T>(): ContextMenuController<T> {
// Register the scope HERE, so the cheat sheet lists Shift+F10 exactly on the surfaces that answer it.
useKeyScope("contextMenu");
const [target, setTarget] = useState<T | null>(null);
const [anchor, setAnchor] = useState<Element | null>(null);
const [open, setOpen] = useState(false);
// What is open right now, for the "…" button's toggle. Read in a handler only, so a ref: state
// would change `buttonProps` on every open and re-render every memoized row that holds it.
const openFor = useRef<{ payload: T } | null>(null);
// The press that dismissed the menu, and whose menu it was: floating-ui's outside-press runs in
// the CAPTURE phase, so a press on the open row's own "…" closes the menu before the button's
// handler sees it (see `buttonProps`).
const dismissedBy = useRef<{ event: Event; payload: T } | null>(null);
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
onOpenChange: (next, event) => {
setOpen(next);
// Drop the target on close so a stale row can't drive a re-open's first paint.
if (!next) setTarget(null);
if (!next) {
dismissedBy.current =
event && openFor.current ? { event, payload: openFor.current.payload } : null;
setTarget(null);
setAnchor(null);
openFor.current = null;
}
},
// A native context menu drops down-and-right from the pointer; flip()/shift() take it the other
// way near an edge.
@@ -94,6 +115,8 @@ export function useContextMenu<T>(): ContextMenuController<T> {
(x: number, y: number, payload: T, contextElement?: Element) => {
setPositionReference({ getBoundingClientRect: () => pointRect(x, y), contextElement });
setTarget(payload);
setAnchor(contextElement ?? null);
openFor.current = { payload };
setOpen(true);
},
[setPositionReference],
@@ -181,7 +204,48 @@ export function useContextMenu<T>(): ContextMenuController<T> {
[openAt, cancelPress],
);
return { target, open, close: () => setOpen(false), triggerProps, popover: pop };
const close = useCallback(() => {
setOpen(false);
setTarget(null);
setAnchor(null);
openFor.current = null;
}, []);
// Set on the "…" button's pointerdown when ITS menu was the open one. The press lands outside the
// panel, so floating-ui's dismiss may already have closed it; either way the click that follows
// closes rather than opens, or the button could never close its own menu.
const pressedWhileOpen = useRef(false);
const buttonProps = useCallback(
(payload: T) => ({
"aria-haspopup": "menu",
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
const dismissed = dismissedBy.current;
pressedWhileOpen.current =
(openFor.current !== null && openFor.current.payload === payload) ||
(dismissed !== null &&
dismissed.event === e.nativeEvent &&
dismissed.payload === payload);
},
onClick: (e: React.MouseEvent<HTMLElement>) => {
// The button sits on a card that plays on click, and inside a row that may be a link.
e.preventDefault();
e.stopPropagation();
// Only a pointer click (detail > 0) reads the press: a keyboard click has no pointerdown, so
// a flag left by a press that never became a click must not turn Enter into a close.
const toggleOff = e.detail > 0 && pressedWhileOpen.current;
pressedWhileOpen.current = false;
if (toggleOff) {
close();
return;
}
const r = e.currentTarget.getBoundingClientRect();
openAt(r.left, r.bottom, payload, e.currentTarget);
},
}),
[openAt, close],
);
return { target, anchor, open, close, triggerProps, buttonProps, popover: pop };
}
/** One trigger per item, stable while the items and the menu are — for a list whose card component
+186
View File
@@ -0,0 +1,186 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Bookmark,
BookmarkX,
Check,
Download,
Eye,
EyeOff,
Info,
ListPlus,
Play,
RotateCcw,
SkipBack,
Tv,
Undo2,
} from "lucide-react";
import { api, type Me, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { qk } from "../lib/queryKeys";
import { pinVideo, useComfortPin } from "./comfortPin";
import { useCopyItems } from "./copyItems";
import { useNavigationActions } from "./navigationContext";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
// E28 C1 — a video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
// and the Home shelves list the same actions in the same order. The card itself keeps only its
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
// download, details, copy, hide, reset, pin — lives here, and only here.
export interface VideoActionHandlers {
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
onResetState?: (id: string) => void;
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
onOpen?: (v: Video, startAt?: number | null) => void;
}
const icon = "w-4 h-4";
/** `open` is the host menu's own flag: the download state and the shelf are fetched only once a
* menu has been opened, since they only label a row. Returns the item builder and the follow-up
* panel to render beside the menu (one per list). */
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
const { t } = useTranslation();
const qc = useQueryClient();
const { openChannel } = useNavigationActions();
const pin = useComfortPin(open);
const copy = useCopyItems();
// Downloads spend server disk and need a real identity: the demo account has none (as the
// player's DownloadButton).
const canDownload = !qc.getQueryData<Me>(qk.me())?.is_demo;
const index = useQuery({
queryKey: qk.downloadIndex(),
queryFn: api.downloadIndex,
enabled: open && canDownload,
staleTime: 10000,
});
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
/** `pin: false` where the host menu carries its own shelf rows (Home's rails). */
const items = (
v: Video,
anchor: Element | null,
{ pin: withPin = true }: { pin?: boolean } = {},
): ActionMenuItem[] => {
const { onState, onToggleSave, onResetState, onOpen } = handlers;
const watched = v.status === "watched";
const hidden = v.status === "hidden";
const inProgress = v.position_seconds > 0 && !watched;
const dl = index.data?.[v.id];
const dlLabel =
dl === "done"
? t("downloads.button.downloaded")
: dl === "queued" || dl === "running" || dl === "paused"
? t("downloads.button.queued")
: t("downloads.button.label");
// The two panels open beside the object; a menu opened with no element to anchor to (none of
// the triggers does that today) leaves them out rather than opening them at the corner.
const at = (kind: "playlist" | "details") =>
anchor ? () => setFollowUp({ kind, video: v, anchor }) : null;
const addTo = at("playlist");
const details = at("details");
return [
...(onOpen
? [
{
id: "play",
label: inProgress ? t("card.continue") : t("card.play"),
icon: <Play className={icon} />,
onSelect: () => onOpen(v, null),
},
...(inProgress
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: <SkipBack className={icon} />,
onSelect: () => onOpen(v, 0),
},
]
: []),
]
: []),
{
id: "channel",
label: t("card.menu.openChannel"),
icon: <Tv className={icon} />,
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
},
{
id: "watched",
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, watched ? "new" : "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
...(addTo
? [
{
id: "playlist",
label: t("playlists.addToPlaylist"),
icon: <ListPlus className={icon} />,
onSelect: addTo,
},
]
: []),
...(canDownload
? [
{
id: "download",
label: dlLabel,
icon: <Download className={icon} />,
onSelect: () => setFollowUp({ kind: "download", video: v }),
},
]
: []),
...(details
? [
{
id: "details",
label: t("card.info"),
icon: <Info className={icon} />,
separatorBefore: true,
onSelect: details,
},
]
: []),
...copy(v.title, v.watch_url).map((it, i) =>
i === 0 && !details ? { ...it, separatorBefore: true } : it,
),
{
id: "hide",
label: hidden ? t("card.unhide") : t("card.hide"),
icon: hidden ? <Eye className={icon} /> : <EyeOff className={icon} />,
separatorBefore: true,
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
},
...(onResetState && isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
];
};
const panel = followUp && <VideoFollowUp state={followUp} onClose={() => setFollowUp(null)} />;
return { items, panel };
}
+3 -1
View File
@@ -22,6 +22,8 @@
"menu": {
"label": "Video",
"unmarkWatched": "Mark unwatched",
"unsave": "Remove from saved"
"unsave": "Remove from saved",
"more": "More actions",
"openChannel": "Open the channel"
}
}
+8
View File
@@ -42,5 +42,13 @@
"movedOver": "“{{title}}” moved to position {{pos}} of {{total}}.",
"dropped": "“{{title}}” dropped at position {{pos}} of {{total}}.",
"cancelled": "Move cancelled; “{{title}}” is back at position {{pos}}."
},
"copy": {
"title": "Copy title",
"name": "Copy name",
"link": "Copy link",
"done": "Copied to the clipboard",
"linkDone": "Link copied to the clipboard",
"failed": "Couldn't copy to the clipboard"
}
}
+3 -1
View File
@@ -22,6 +22,8 @@
"menu": {
"label": "Videó",
"unmarkWatched": "Megnézés visszavonása",
"unsave": "Törlés a mentettek közül"
"unsave": "Törlés a mentettek közül",
"more": "További műveletek",
"openChannel": "Csatorna megnyitása"
}
}
+8
View File
@@ -42,5 +42,13 @@
"movedOver": "„{{title}}” a(z) {{pos}}. helyre került a(z) {{total}} közül.",
"dropped": "„{{title}}” letéve a(z) {{pos}}. helyre a(z) {{total}} közül.",
"cancelled": "Mozgatás megszakítva; „{{title}}” visszakerült a(z) {{pos}}. helyre."
},
"copy": {
"title": "Cím másolása",
"name": "Név másolása",
"link": "Link másolása",
"done": "Vágólapra másolva",
"linkDone": "Link a vágólapra másolva",
"failed": "Nem sikerült a vágólapra másolni"
}
}
+6
View File
@@ -737,6 +737,12 @@ body {
.group:focus-within .reveal-idle {
opacity: 0;
}
/* The mirror of `.reveal-hover`: a control that exists ONLY on touch, because a mouse reaches the
same action another way — the video card's "…" beside the title, whose mouse twin sits in the
hover row on the picture (E28 C1). `display: none`, so it is no Tab stop either. */
.touch-only {
display: none;
}
}
@media (hover: none) {
.reveal-idle {
+15
View File
@@ -0,0 +1,15 @@
// docs/DESIGN.md "Text stays selectable": a click that ends a text selection does nothing. The
// mouseup of a drag-select fires a click on whatever the drag started in, so a title that opens its
// video on click would play it the moment the reader finished selecting its words to copy them.
// A plain click collapses any selection on mousedown, so a non-empty selection that touches the
// element at click time can only be the one this gesture just made.
/** True when the page's selection is non-empty and starts or ends inside `el`. */
export function endsTextSelection(el: Element): boolean {
const sel = typeof window === "undefined" ? null : window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return false;
return (
(!!sel.anchorNode && el.contains(sel.anchorNode)) ||
(!!sel.focusNode && el.contains(sel.focusNode))
);
}