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:
@@ -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 |
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user