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:
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user