The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs (0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem class (rare 8/9/12/13px -> rem arbitraries). Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their icon/badge px sizes go rem so the rails keep pace with their (now-scaling) labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media thumbnails, page-width caps and fixed container widths deliberately stay px. Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header clearance 72->88px, nav slot 246->302px — all scale proportionally.
634 lines
28 KiB
TypeScript
634 lines
28 KiB
TypeScript
import { memo, useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useNavigationActions } from "./NavigationProvider";
|
||
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, VideoStatus } 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: VideoStatus) => 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: VideoStatus) => (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 }: { video: Video }) {
|
||
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"
|
||
? // fixed live-red, not the status token — "live" is a broadcast indicator, not an error
|
||
{ 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="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
|
||
{/* 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-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-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-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. */}
|
||
{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-xs 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-px", 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>
|
||
);
|
||
}
|
||
|
||
// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
|
||
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
|
||
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
|
||
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.9–1.3), so the boxes must grow with
|
||
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
|
||
// 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: 192, // w-48 — six buttons at 28 + five 4px gaps = 188
|
||
channel: 128, // w-32
|
||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||
views: 64, // w-16
|
||
published: 192, // w-48
|
||
} as const;
|
||
const ROW_GAP = 12; // gap-3
|
||
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
|
||
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing
|
||
|
||
// What the row must measure before each column earns its place — cumulative, so each appears only
|
||
// once everything to its left AND a readable title already fit. Calibrated at the default text
|
||
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
|
||
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
|
||
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
|
||
const ROW_FITS = {
|
||
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
|
||
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
|
||
views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
|
||
published:
|
||
ROW_BASE +
|
||
ROW_GAP +
|
||
ROW_COL.channel +
|
||
ROW_GAP +
|
||
ROW_COL.meta +
|
||
ROW_GAP +
|
||
ROW_COL.views +
|
||
ROW_GAP +
|
||
ROW_COL.published,
|
||
};
|
||
|
||
function VideoCard({
|
||
video,
|
||
view,
|
||
width,
|
||
onState,
|
||
onResetState,
|
||
onToggleSave,
|
||
onOpen,
|
||
}: {
|
||
video: Video;
|
||
view: FeedView;
|
||
/** 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;
|
||
}) {
|
||
const { t, i18n } = useTranslation();
|
||
const { openChannel } = useNavigationActions();
|
||
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. The count comes in two shapes from one span —
|
||
// one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
|
||
// ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
|
||
// on every line and the tooltip carries the figure regardless.
|
||
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 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}
|
||
data-testid="video-card-channel"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
openChannel(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);
|
||
const fits = (needs: number) => width === 0 || width >= needs;
|
||
return (
|
||
<div
|
||
data-testid="video-card"
|
||
data-video-id={video.id}
|
||
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 the row narrows, least important first: a title with no date beats a
|
||
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
|
||
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
||
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
||
<div className="min-w-0 flex-1">{title}</div>
|
||
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
|
||
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
||
{/* 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. */}
|
||
{fits(ROW_FITS.views) && (
|
||
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
|
||
{viewsCell(false)}
|
||
</div>
|
||
)}
|
||
{fits(ROW_FITS.published) && (
|
||
<div className="shrink-0 w-48 text-sm text-muted truncate">{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 views = viewsCell(true);
|
||
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
|
||
data-testid="video-card"
|
||
data-video-id={video.id}
|
||
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
|
||
data-testid="video-card"
|
||
data-video-id={video.id}
|
||
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);
|