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

- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
This commit is contained in:
2026-09-26 01:23:44 +02:00
parent 9d7526ee57
commit 75ae810866
26 changed files with 1189 additions and 557 deletions
+19 -21
View File
@@ -1,7 +1,7 @@
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
import { FeedVideoMenu } from "./ComfortPinMenu";
import { VideoMenu } from "./VideoMenu";
import { useContextMenu } from "./ui/useContextMenu";
import VirtualGrid from "./ui/VirtualGrid";
@@ -9,15 +9,15 @@ import VirtualGrid from "./ui/VirtualGrid";
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
// when the bare row became a single line and halved (82 -> 46). The card figures are the roughest
// of the set by nature — a card's height follows its column width (measured ~307 at 3 columns,
// ~301 at 4, taller still at 2), so no single constant is right for every window.
// when the E28 C1 action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226).
// The card figures are the roughest of the set by nature — a card's height follows its column width
// (262 measured at 4 columns in a 1268px window), so no single constant is right for every window.
const ROW_EST: Record<FeedView, number> = {
cards: 340,
cardsSmall: 257,
rows: 115,
cards: 262,
cardsSmall: 226,
rows: 117,
rowsCompact: 46,
tiles: 132,
tiles: 138,
};
export interface VirtualFeedProps {
@@ -47,11 +47,12 @@ export default function VirtualFeed({
fetchNextPage,
}: VirtualFeedProps) {
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
// The card menu (its state actions and the comfort-shelf pin, E24 S3) lives HERE rather than in each
// of the feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting it at this level is also
// what gives the channel page the gesture for free — it renders `<Feed channelScoped>`, so it is
// the same component all the way down.
const pinMenu = useContextMenu<Video>();
// The card menu (the video's whole action list, E28 C1) lives HERE rather than in each of the
// feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting
// it at this level is also what gives the channel page the menu for free — it renders
// `<Feed channelScoped>`, so it is the same component all the way down.
const menu = useContextMenu<Video>();
const openId = menu.open ? menu.target?.id : undefined;
return (
<>
<VirtualGrid
@@ -76,18 +77,15 @@ export default function VirtualFeed({
width={thumb ? 0 : itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
onOpen={onOpen}
menuProps={pinMenu.triggerProps}
menuProps={menu.triggerProps}
menuButtonProps={menu.buttonProps}
// A boolean per card, not the open id: only the two cards whose answer changes re-render.
menuOpen={openId === v.id}
/>
)}
/>
<FeedVideoMenu
menu={pinMenu}
onState={onState}
onToggleSave={onToggleSave}
onResetState={onResetState}
/>
<VideoMenu menu={menu} handlers={{ onState, onToggleSave, onResetState, onOpen }} />
</>
);
}