The xl breakpoint was a fact about the FEED's toolbar, hard-coded into the switcher that E4's managers are meant to reuse — their rows are far emptier and would have hidden the label for no reason. It's a prop now, defaulting to icon-only. The two views spans collapse into one builder, which also fixes a regression I'd just added: splitting them put the exact-count tooltip on the number alone, so hovering the word gave nothing.
578 lines
25 KiB
TypeScript
578 lines
25 KiB
TypeScript
import { memo, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Bookmark,
|
|
Check,
|
|
CheckCheck,
|
|
Clock,
|
|
Eye,
|
|
EyeOff,
|
|
Play,
|
|
Radio,
|
|
RotateCcw,
|
|
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
|
|
Video as VideoIcon,
|
|
} from "lucide-react";
|
|
import Avatar from "./Avatar";
|
|
import AddToPlaylist from "./AddToPlaylist";
|
|
import DownloadButton from "./DownloadButton";
|
|
import clsx from "clsx";
|
|
import type { Video } from "../lib/api";
|
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
|
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
|
|
|
function Actions({
|
|
video,
|
|
onState,
|
|
onResetState,
|
|
onToggleSave,
|
|
dense = false,
|
|
}: {
|
|
video: Video;
|
|
onState: (id: string, status: string) => void;
|
|
onResetState?: (id: string) => void;
|
|
onToggleSave: (id: string, saved: boolean) => void;
|
|
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
|
|
dense?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const act = (status: string) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onState(video.id, video.status === status ? "new" : status);
|
|
};
|
|
// Pristine = never opened: default status and no resume position. The reset clears the
|
|
// whole state (incl. an in-progress position the un-watch toggle can't touch).
|
|
const resettable = video.status !== "new" || video.position_seconds > 0;
|
|
const toggleSave = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onToggleSave(video.id, !video.saved);
|
|
};
|
|
// One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
|
|
// stay the same size instead of two of them keeping their own default.
|
|
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
|
|
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
|
|
// narrowest the last button used to hang outside the card. 154 at dense.
|
|
const btn = clsx(
|
|
"rounded-md hover:bg-surface text-muted hover:text-fg",
|
|
dense ? "p-1" : "p-1.5"
|
|
);
|
|
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 opacity-0 group-hover:opacity-100 transition", dense ? "gap-0.5" : "gap-1")}>
|
|
<button
|
|
onClick={act("watched")}
|
|
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
|
|
className={clsx(btn, video.status === "watched" && "text-accent")}
|
|
>
|
|
{video.status === "watched" ? (
|
|
<CheckCheck className="w-4 h-4" />
|
|
) : (
|
|
<Check className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={toggleSave}
|
|
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
|
className={clsx(btn, video.saved && "fill-current text-accent")}
|
|
>
|
|
<Bookmark className="w-4 h-4" />
|
|
</button>
|
|
<AddToPlaylist videoId={video.id} className={btn} />
|
|
<DownloadButton videoId={video.id} title={video.title} className={btn} />
|
|
<button
|
|
onClick={act("hidden")}
|
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
|
className={clsx(btn, video.status === "hidden" && "text-accent")}
|
|
>
|
|
{video.status === "hidden" ? (
|
|
<Eye className="w-4 h-4" />
|
|
) : (
|
|
<EyeOff className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
{onResetState && resettable && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onResetState(video.id);
|
|
}}
|
|
title={t("card.resetState")}
|
|
className={btn}
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
function openInApp(
|
|
e: React.MouseEvent,
|
|
video: Video,
|
|
onOpen?: (v: Video, startAt?: number | null) => void
|
|
): void {
|
|
if (!onOpen || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
onOpen(video);
|
|
}
|
|
|
|
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
|
|
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
|
|
// no percentage to draw, so the two are deliberately separate predicates.
|
|
function isInProgress(video: Video): boolean {
|
|
return video.position_seconds > 0 && video.status !== "watched";
|
|
}
|
|
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
|
|
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
|
|
function resumePct(video: Video): number {
|
|
return isInProgress(video) && video.duration_seconds
|
|
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
|
|
: 0;
|
|
}
|
|
|
|
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
|
|
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
|
|
// floating chips over artwork and a table cell are different presentations, and forcing one
|
|
// component to do both would be a variant-flag knot for no reuse.
|
|
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
|
|
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
|
|
// videoBadges(video) -> Badge[] — and keep only the markup separate.
|
|
//
|
|
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
|
|
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
|
|
// beside them to different places, and pulled it left again whenever the duration was absent
|
|
// (live/upcoming) — visible as a ragged edge down the page.
|
|
function RowMetaCells({ video, className }: { video: Video; className?: string }) {
|
|
const { t } = useTranslation();
|
|
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
|
|
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
|
|
// because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
|
|
// "live" dot and a "stream" dot would be the same dot.
|
|
const status =
|
|
video.live_status === "live"
|
|
? { Icon: Radio, cls: "text-red-500", label: t("card.live") }
|
|
: video.live_status === "upcoming"
|
|
? { Icon: Clock, cls: "", label: t("card.upcoming") }
|
|
: video.live_status === "was_live"
|
|
? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
|
|
: null;
|
|
const StatusIcon = status?.Icon;
|
|
// 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
|
|
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
|
|
// the text-size setting the same way its contents do.
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted",
|
|
className
|
|
)}
|
|
>
|
|
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
|
|
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
|
|
<span className="text-right tabular-nums">
|
|
{video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
|
|
</span>
|
|
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
|
|
bare span has no role, and screen readers don't dependably surface title on one), while a
|
|
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
|
|
in Actions — the label must not tell anyone to click something that doesn't respond. */}
|
|
<span>
|
|
{StatusIcon && (
|
|
<span role="img" aria-label={status.label} title={status.label}>
|
|
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span>
|
|
{video.saved && (
|
|
<span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
|
|
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Thumb({
|
|
video,
|
|
className,
|
|
onOpen,
|
|
small = false,
|
|
}: {
|
|
video: Video;
|
|
className?: string;
|
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
|
/** 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;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const inProgress = isInProgress(video);
|
|
const pct = resumePct(video);
|
|
|
|
// Open in the in-app player at an explicit position (null = resume from saved).
|
|
const open = (startAt: number | null) => (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onOpen?.(video, startAt);
|
|
};
|
|
|
|
return (
|
|
<a
|
|
href={video.watch_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
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"
|
|
/>
|
|
) : (
|
|
<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-[11px] font-medium px-1.5 py-0.5 rounded">
|
|
{formatDuration(video.duration_seconds)}
|
|
</span>
|
|
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
|
<span
|
|
className={`absolute bottom-1.5 right-1.5 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
|
|
video.live_status === "live" ? "bg-red-500/90 text-white" : "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-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
|
|
{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. */}
|
|
{onOpen && (
|
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
{inProgress ? (
|
|
<>
|
|
<button
|
|
onClick={open(null)}
|
|
title={t("card.continueTitle")}
|
|
aria-label={t("card.continue")}
|
|
className={clsx(
|
|
"inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
|
|
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
|
|
)}
|
|
>
|
|
<Play className="w-4 h-4 fill-current" />
|
|
{!small && t("card.continue")}
|
|
</button>
|
|
<button
|
|
onClick={open(0)}
|
|
title={t("card.restartTitle")}
|
|
aria-label={t("card.restart")}
|
|
className={clsx(
|
|
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
|
|
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
|
|
)}
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
{!small && t("card.restart")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={open(null)}
|
|
title={t("card.play")}
|
|
aria-label={t("card.play")}
|
|
className={clsx(
|
|
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
|
|
small ? "w-9 h-9" : "w-12 h-12"
|
|
)}
|
|
>
|
|
<Play className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} />
|
|
</button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function VideoCard({
|
|
video,
|
|
view,
|
|
onState,
|
|
onResetState,
|
|
onToggleSave,
|
|
onOpenChannel,
|
|
onOpen,
|
|
}: {
|
|
video: Video;
|
|
view: FeedView;
|
|
onState: (id: string, status: string) => void;
|
|
onResetState?: (id: string) => void;
|
|
onToggleSave: (id: string, saved: boolean) => void;
|
|
onOpenChannel?: (channelId: string, channelName: string) => void;
|
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const spec = FEED_VIEW_SPEC[view];
|
|
const watched = video.status === "watched";
|
|
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
|
|
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
|
|
// line here rather than let the mode silently lose them.
|
|
const noThumb = !spec.thumb;
|
|
// Each datum on its own, so the one-line row can give each a column of its own while the stacked
|
|
// layouts still run them together as one sentence.
|
|
// Two shapes for the count: the stacked layouts read it as prose ("1.5K views · 2 min ago"), so
|
|
// they need the word; the row's column doesn't — a table doesn't repeat its unit on every line,
|
|
// and the tooltip has the exact figure anyway. Same tooltip either way.
|
|
// One span either way, so the exact-count tooltip covers all of whatever is rendered: the stacked
|
|
// layouts read it as prose and need the word, the row's column doesn't — a table doesn't repeat
|
|
// its unit on every line.
|
|
const viewsCell = (withWord: boolean) =>
|
|
video.view_count != null && (
|
|
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
|
|
{formatViews(video.view_count)}
|
|
{withWord && ` ${t("card.views")}`}
|
|
</span>
|
|
);
|
|
const views = viewsCell(true);
|
|
const published = (
|
|
<>
|
|
{relativeTime(video.published_at)}
|
|
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
|
</>
|
|
);
|
|
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
|
|
// title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
|
|
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
|
|
// repeats what's already legible is noise.
|
|
const titleRef = useRef<HTMLAnchorElement>(null);
|
|
const channelRef = useRef<HTMLButtonElement>(null);
|
|
const [titleClamped, setTitleClamped] = useState(false);
|
|
const [channelClamped, setChannelClamped] = useState(false);
|
|
useEffect(() => {
|
|
const cut = (el: HTMLElement) =>
|
|
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
|
|
const els: [HTMLElement | null, (v: boolean) => void][] = [
|
|
[titleRef.current, setTitleClamped],
|
|
[channelRef.current, setChannelClamped],
|
|
];
|
|
const check = () => els.forEach(([el, set]) => el && set(cut(el)));
|
|
check();
|
|
const ro = new ResizeObserver(check);
|
|
els.forEach(([el]) => el && ro.observe(el));
|
|
return () => ro.disconnect();
|
|
// `view` matters as much as the text: switching views returns a different branch, so React can
|
|
// remount these nodes — without it the observer would be left watching detached ones.
|
|
}, [video.title, video.channel_title, view]);
|
|
const title = (
|
|
<a
|
|
ref={titleRef}
|
|
href={video.watch_url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
onClick={(e) => openInApp(e, video, onOpen)}
|
|
title={titleClamped ? video.title ?? undefined : undefined}
|
|
className={clsx(
|
|
"font-medium leading-snug hover:text-accent",
|
|
noThumb ? "block truncate" : "line-clamp-2"
|
|
)}
|
|
>
|
|
{video.title}
|
|
</a>
|
|
);
|
|
const channelButton = (
|
|
<button
|
|
ref={channelRef}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
|
|
}}
|
|
title={channelClamped ? video.channel_title ?? undefined : undefined}
|
|
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
|
|
>
|
|
{video.channel_title}
|
|
</button>
|
|
);
|
|
const actions = (
|
|
<Actions
|
|
video={video}
|
|
onState={onState}
|
|
onResetState={onResetState}
|
|
onToggleSave={onToggleSave}
|
|
dense={spec.dense}
|
|
/>
|
|
);
|
|
// 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
|
|
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
|
|
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
|
|
// jumping as the pointer crosses it is a lot more restless than a card doing it.
|
|
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
|
|
|
|
if (spec.family === "row") {
|
|
// A table without being one. Every column is a FIXED width, which is what makes the same datum
|
|
// land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
|
|
// renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
|
|
// still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
|
|
// Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
|
|
// languages, since both have caught me out here:
|
|
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 48 (the bare
|
|
// "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
|
|
// 139) · published 183 ("11 months ago · Feb 28, 2026"; HU is shorter) · channel 128,
|
|
// which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
|
|
// of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
|
|
// still reads while a clipped view count loses the number.
|
|
// Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
|
|
// whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
|
|
// instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
|
|
// 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
|
|
// cell was a flex row, so its contents slid about — see RowMetaCells).
|
|
// The title takes what's left, which is constant per container width — so it aligns too.
|
|
if (noThumb) {
|
|
const pct = resumePct(video);
|
|
return (
|
|
<div 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. */}
|
|
<div className="shrink-0 w-48">{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 space runs out, least important first: a title with no date beats a
|
|
date with no title. Breakpoints are the viewport, not this row, so they're rough — the
|
|
rail and filter panel move the row's width too — but they're pitched to keep the title
|
|
alive at the narrow end rather than to hold every column to the last pixel. */}
|
|
<div className="min-w-0 flex-1">{title}</div>
|
|
<div className="shrink-0 w-32 hidden sm:block">{channelButton}</div>
|
|
<RowMetaCells video={video} className="hidden md:grid" />
|
|
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
|
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
|
|
took this from 144 and handed the difference to the title. */}
|
|
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate hidden lg:block">
|
|
{viewsCell(false)}
|
|
</div>
|
|
<div className="shrink-0 w-48 text-sm text-muted truncate hidden xl:block">{published}</div>
|
|
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
|
|
{pct > 0 && (
|
|
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
|
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
|
|
// data together as one sentence; the one-line row above gives each datum its own column and has
|
|
// already returned, so building this for it would be waste dressed up as shared code.
|
|
const textBlock = (
|
|
<>
|
|
{title}
|
|
<div className="mt-0.5">{channelButton}</div>
|
|
<div className="text-xs text-muted mt-0.5">
|
|
{views}
|
|
{views && " · "}
|
|
{published}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (spec.family === "row") {
|
|
return (
|
|
<div className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
|
|
<Thumb
|
|
video={video}
|
|
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
|
onOpen={onOpen}
|
|
small
|
|
/>
|
|
<div className="min-w-0 flex-1 flex flex-col">
|
|
{textBlock}
|
|
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
|
|
reserves — which frees the ~168px the docked-right block used, so the block still
|
|
works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
|
|
already stretches this column to full height), so they land in the same place
|
|
whether the title above them ran to one line or three. */}
|
|
{spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
|
|
</div>
|
|
{!spec.actionsBelow && actions}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
// 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.
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
|
|
"flex flex-col",
|
|
spec.dense ? "p-2" : "p-2.5",
|
|
watched && "opacity-55"
|
|
)}
|
|
>
|
|
{/* 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} />
|
|
<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. */}
|
|
{!spec.dense && (
|
|
<Avatar
|
|
src={video.channel_thumbnail}
|
|
fallback={video.channel_title ?? ""}
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Memoized so appending a feed page only renders the new cards, not every existing
|
|
// one (the callbacks from Feed are stable; non-overridden video objects keep their
|
|
// identity across renders).
|
|
export default memo(VideoCard);
|