fix(feed): stop the compact row's meta sliding about

The cell was a flex row, so whatever sat left pushed the rest: a 36:36
and a 3:31:09 put the marker beside them at different x, and a live video
— which has no duration at all — pulled it left again. The column edges
lined up, which is all I had checked; the data inside them did not.

Each datum now has a fixed slot in a grid, the duration right-aligned as
a number should be. The chip becomes an icon, about a letter wide: it is
the least important thing in the row, and three states have to stay apart
by SHAPE — in the youtube scheme --accent is red, so a live dot and a
stream dot would be the same dot.
This commit is contained in:
2026-07-17 00:41:33 +02:00
parent 26c7ed7471
commit 3de7e0ba0e
+62 -38
View File
@@ -4,10 +4,14 @@ 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";
@@ -134,34 +138,55 @@ function resumePct(video: Video): number {
}
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as its own column — deliberately not shared with Thumb's badges: floating
// chips over artwork and a text column 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-else-live, 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
// 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.
function InlineBadges({ video }: { video: Video }) {
//
// 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();
// The live/stream chips keep the small-caps pill: they're LABELS. The duration is a VALUE, so
// here it's plain text at the row's size — over a thumbnail it needs a chip to carry contrast
// against the artwork, but in a text column that background would just make one data column
// look unlike its neighbours.
const label = "shrink-0 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide";
// 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;
return (
<div className="flex items-center gap-1.5">
{video.duration_seconds != null ? (
<span className="shrink-0 tabular-nums">{formatDuration(video.duration_seconds)}</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={clsx(label, video.live_status === "live" ? "bg-red-500/90 text-white" : "bg-surface text-fg")}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className={clsx(label, "bg-accent text-accent-fg")}>{t("card.stream")}</span>
)}
{video.saved && <Bookmark className="shrink-0 w-3.5 h-3.5 text-accent fill-current" />}
<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>
<span>
{StatusIcon && (
<span title={status.label}>
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} aria-hidden="true" />
<span className="sr-only">{status.label}</span>
</span>
)}
</span>
<span>
{video.saved && (
<span title={t("card.savedRemove")}>
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" aria-hidden="true" />
<span className="sr-only">{t("card.savedRemove")}</span>
</span>
)}
</span>
</div>
);
}
@@ -395,16 +420,17 @@ function VideoCard({
// VirtualFeed renders each row as its own subtree, so there is no shared grid to align to.
// 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) · badges 130 ("11:46:50" + a STREAM chip + the saved pip —
// the library really does hold an 11-hour stream) · views 139 ("101.7K megtekintés"; the
// English "views" is only 79) · 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.
// Two ways this went wrong before, both worth avoiding: sizing off a PAGE of data (badges came
// from 60 videos whose longest was 2:00:58, and the column then wrapped in the one layout that
// promises single lines), and measuring with a hand-built probe instead of the real element
// (that read "101.7K megtekintés" as 122px when the live cell renders it at 139).
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 139 ("101.7K
// megtekintés"; the English "views" is only 79) · 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);
@@ -415,9 +441,7 @@ function VideoCard({
<div className="shrink-0 w-48">{actions}</div>
<div className="min-w-0 flex-1">{title}</div>
<div className="shrink-0 w-32">{channelButton}</div>
<div className="shrink-0 w-36 text-sm text-muted">
<InlineBadges video={video} />
</div>
<RowMetaCells video={video} />
<div className="shrink-0 w-36 text-sm text-muted truncate">{views}</div>
<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. */}