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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user