From 75ae8108661edde552624158c00ff7d357483a14 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 01:23:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(design):=20a=20video's=20one=20action=20li?= =?UTF-8?q?st=20(C1)=20=E2=80=94=20the=20card's=20"=E2=80=A6",=20the=20men?= =?UTF-8?q?u=20holds=20the=20rest=20(E28=20S3e1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/DESIGN.md | 9 + frontend/src/components/AddToPlaylist.tsx | 87 ++-- frontend/src/components/ComfortPinMenu.tsx | 68 +-- .../src/components/FeedVideoMenu.dom.test.tsx | 134 ------ frontend/src/components/Home.tsx | 13 + .../components/VideoCard.actions.dom.test.tsx | 93 ++++ .../src/components/VideoCard.nav.dom.test.tsx | 23 + frontend/src/components/VideoCard.tsx | 415 +++++++++--------- frontend/src/components/VideoFollowUp.tsx | 35 ++ .../src/components/VideoInfoCard.dom.test.tsx | 43 +- frontend/src/components/VideoInfoCard.tsx | 133 +++--- .../src/components/VideoMenu.dom.test.tsx | 209 +++++++++ frontend/src/components/VideoMenu.tsx | 30 ++ frontend/src/components/VirtualFeed.tsx | 40 +- frontend/src/components/copyItems.tsx | 58 +++ frontend/src/components/ui/ActionMenu.tsx | 10 +- frontend/src/components/ui/MoreButton.tsx | 37 ++ frontend/src/components/ui/README.md | 8 +- frontend/src/components/ui/useContextMenu.ts | 70 ++- frontend/src/components/useVideoActions.tsx | 186 ++++++++ frontend/src/i18n/locales/en/card.json | 4 +- frontend/src/i18n/locales/en/common.json | 8 + frontend/src/i18n/locales/hu/card.json | 4 +- frontend/src/i18n/locales/hu/common.json | 8 + frontend/src/index.css | 6 + frontend/src/lib/textSelection.ts | 15 + 26 files changed, 1189 insertions(+), 557 deletions(-) delete mode 100644 frontend/src/components/FeedVideoMenu.dom.test.tsx create mode 100644 frontend/src/components/VideoCard.actions.dom.test.tsx create mode 100644 frontend/src/components/VideoFollowUp.tsx create mode 100644 frontend/src/components/VideoMenu.dom.test.tsx create mode 100644 frontend/src/components/VideoMenu.tsx create mode 100644 frontend/src/components/copyItems.tsx create mode 100644 frontend/src/components/ui/MoreButton.tsx create mode 100644 frontend/src/components/useVideoActions.tsx create mode 100644 frontend/src/lib/textSelection.ts diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7823f2f2..e203d111 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 ` - )} - {small ? ( - - - - ) : ( - - )} - - ) : ( - - - - )} - + {onOpen && ( +
+ {inProgress ? ( + <> + {small ? ( + + + + ) : ( + + )} + {small ? ( + + + + ) : ( + + )} + + ) : ( + + + + )} +
+ )} + {/* Resume progress bar (sits at the very bottom, under the duration badge). */} + {pct > 0 && ( +
+
+
+ )} +
+ {bar && ( +
{bar}
)} - {/* 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. */} - - {/* Resume progress bar (sits at the very bottom, under the duration badge). */} - {pct > 0 && ( -
-
-
- )} - +
); } @@ -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; + /** 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; + /** 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 = ( - +
+ +
); // 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. */} -
-
{actions}
- -
+ they're the shortest pointer trip from it. ROW_COL.actions counts their width. */} +
{actions}
{/* 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. */} - + + } + />
{/* 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" /> )} -
- {textBlock} -
{actions}
-
+
{textBlock}
+ {more && ( + + )}
); diff --git a/frontend/src/components/VideoFollowUp.tsx b/frontend/src/components/VideoFollowUp.tsx new file mode 100644 index 00000000..da6e38b5 --- /dev/null +++ b/frontend/src/components/VideoFollowUp.tsx @@ -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 ( + + + + ); + if (state.kind === "playlist") + return ; + return ; +} diff --git a/frontend/src/components/VideoInfoCard.dom.test.tsx b/frontend/src/components/VideoInfoCard.dom.test.tsx index a175a3b0..c4f22f49 100644 --- a/frontend/src/components/VideoInfoCard.dom.test.tsx +++ b/frontend/src/components/VideoInfoCard.dom.test.tsx @@ -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