improvement(feed): make the compact row a real table
Actions lead the row: the title is what you read, so that is the shortest pointer trip to them. Every column after it is a fixed width, which is what puts the same datum on the same vertical line in every row — a CSS grid cannot do it, since VirtualFeed renders each row as its own subtree. Widths come from measuring the real data, not from taste. Metadata rises to the channel name's size, and the duration drops its chip: over a thumbnail that background carries contrast against the artwork, but in a text column it only made one data column look unlike its neighbours.
This commit is contained in:
@@ -133,24 +133,24 @@ function resumePct(video: Video): number {
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Thumb overlays duration / live state / saved ON the image. The thumbnail-less row has no image,
|
||||
// so it renders the same FACTS inline here — deliberately not a shared component with Thumb's
|
||||
// badges: floating chips over artwork and inline text chips are different presentations, and
|
||||
// forcing one component to do both would be a variant-flag knot for no reuse.
|
||||
// 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
|
||||
// videoBadges(video) -> Badge[] — and keep only the markup separate.
|
||||
function InlineBadges({ video }: { video: Video }) {
|
||||
const { t } = useTranslation();
|
||||
// Inline-flow, not flex: these sit inside the meta line's " · "-separated text.
|
||||
const base = "inline-block mr-1.5 px-1.5 py-0.5 rounded";
|
||||
const label = clsx(base, "text-[10px] font-semibold uppercase tracking-wide");
|
||||
// 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";
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{video.duration_seconds != null ? (
|
||||
<span className={clsx(base, "bg-surface text-fg text-[11px] font-medium tabular-nums")}>
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
<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")}
|
||||
@@ -161,10 +161,8 @@ function InlineBadges({ video }: { video: Video }) {
|
||||
{video.live_status === "was_live" && (
|
||||
<span className={clsx(label, "bg-accent text-accent-fg")}>{t("card.stream")}</span>
|
||||
)}
|
||||
{video.saved && (
|
||||
<Bookmark className="inline-block mr-1.5 w-3.5 h-3.5 align-[-2px] text-accent fill-current" />
|
||||
)}
|
||||
</>
|
||||
{video.saved && <Bookmark className="shrink-0 w-3.5 h-3.5 text-accent fill-current" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,21 +316,26 @@ function VideoCard({
|
||||
// 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;
|
||||
const meta = (
|
||||
// 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.
|
||||
const views = video.view_count != null && (
|
||||
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
|
||||
{formatViews(video.view_count)} {t("card.views")}
|
||||
</span>
|
||||
);
|
||||
const published = (
|
||||
<>
|
||||
{noThumb && <InlineBadges video={video} />}
|
||||
{video.view_count != null && (
|
||||
<>
|
||||
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
|
||||
{formatViews(video.view_count)} {t("card.views")}
|
||||
</span>{" "}
|
||||
·{" "}
|
||||
</>
|
||||
)}
|
||||
{relativeTime(video.published_at)}
|
||||
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
|
||||
</>
|
||||
);
|
||||
const meta = (
|
||||
<>
|
||||
{views}
|
||||
{views && " · "}
|
||||
{published}
|
||||
</>
|
||||
);
|
||||
// Show the full title in a native tooltip only when it's actually cut off — vertically where it
|
||||
// clamps to 2 lines, horizontally where the one-line row truncates it.
|
||||
const titleRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -401,22 +404,30 @@ function VideoCard({
|
||||
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
|
||||
|
||||
if (spec.family === "row") {
|
||||
// No thumbnail => everything fits on ONE line: title (truncating into whatever's left), then
|
||||
// channel, badges+meta and the actions all holding a fixed place at the right. How wide the
|
||||
// line is allowed to get is the layout's call — see each view's maxCol in lib/feedView.ts.
|
||||
// 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 CSS grid can't do it for us, because
|
||||
// VirtualFeed renders each row as its own subtree, so there is no shared grid to align to.
|
||||
// Widths come from measuring the real data at text-sm (60-video sample), then taking the next
|
||||
// Tailwind step up: channel names run to 342px at worst (they truncate — most are far shorter),
|
||||
// views peak at 79, duration + a live chip + the saved pip at ~118, "11 months ago ·
|
||||
// Feb 28, 2026" at 181. The actions are 188 (6 x 28 + 5 x 4). Re-measure before trimming any
|
||||
// of them — badges first looked like 102 until the chips' own margins were counted, and the
|
||||
// column silently wrapped to a second line.
|
||||
// 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>
|
||||
<div className="min-w-0 flex-1">{title}</div>
|
||||
<div className="shrink-0 max-w-[14rem]">{channelButton}</div>
|
||||
<div className="shrink-0 text-xs text-muted whitespace-nowrap">{meta}</div>
|
||||
{/* Fixed slot, right-aligned: the reset button only exists for videos that have watch
|
||||
state, so without a reserved width the meta ahead of it would sit 32px further right
|
||||
on some rows than others — visible jitter in a layout whose whole point is that the
|
||||
columns line up. The six buttons measure 188px (6 x 28 + 5 x 4 gap); w-48 is the
|
||||
nearest step above that. */}
|
||||
<div className="shrink-0 w-48 flex justify-end">{actions}</div>
|
||||
<div className="shrink-0 w-40">{channelButton}</div>
|
||||
<div className="shrink-0 w-32 text-sm text-muted">
|
||||
<InlineBadges video={video} />
|
||||
</div>
|
||||
<div className="shrink-0 w-24 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. */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
||||
|
||||
Reference in New Issue
Block a user