fix(feed): drop the compact row's columns by the ROW's width, not the viewport
The viewport was the wrong signal: the nav rail and filter panel move the row's width by ~430px between them, so at a 1280 window with both pinned every column still qualified while the row had 762px for 766px of them — the title squeezed back to nothing, the exact bug the breakpoints were meant to fix. My own comment predicted it and I didn't follow it through. VirtualFeed already measures the container for colCount, so it now passes the item's real width down. Measured in the failing config: title -4 -> 359 with the date column standing down.
This commit is contained in:
@@ -149,7 +149,7 @@ function resumePct(video: Video): number {
|
||||
// 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 }) {
|
||||
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
|
||||
@@ -168,12 +168,7 @@ function RowMetaCells({ video, className }: { video: Video; className?: string }
|
||||
// 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
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
@@ -327,9 +322,20 @@ function Thumb({
|
||||
);
|
||||
}
|
||||
|
||||
// What the one-line row needs before each column earns its place, cumulative from the left:
|
||||
// actions (192) + a title worth reading (~120) + gaps + the row's padding, then each column plus
|
||||
// its gap. Driven by the ROW's measured width, never the viewport — see VirtualFeed's itemWidth.
|
||||
const ROW_FITS = {
|
||||
channel: 490,
|
||||
meta: 606,
|
||||
views: 682,
|
||||
published: 886,
|
||||
};
|
||||
|
||||
function VideoCard({
|
||||
video,
|
||||
view,
|
||||
width,
|
||||
onState,
|
||||
onResetState,
|
||||
onToggleSave,
|
||||
@@ -338,6 +344,8 @@ function VideoCard({
|
||||
}: {
|
||||
video: Video;
|
||||
view: FeedView;
|
||||
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
|
||||
width: number;
|
||||
onState: (id: string, status: string) => void;
|
||||
onResetState?: (id: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
@@ -458,6 +466,7 @@ function VideoCard({
|
||||
// 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 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
|
||||
@@ -465,20 +474,24 @@ function VideoCard({
|
||||
<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. */}
|
||||
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>
|
||||
<div className="shrink-0 w-32 hidden sm:block">{channelButton}</div>
|
||||
<RowMetaCells video={video} className="hidden md:grid" />
|
||||
{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. */}
|
||||
<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>
|
||||
{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">
|
||||
|
||||
@@ -86,6 +86,12 @@ export default function VirtualFeed({
|
||||
// Distance from the scroll element's content top to where this list starts (the
|
||||
// feed toolbar sits above it inside the same scroll area).
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
|
||||
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
|
||||
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
|
||||
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
|
||||
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
|
||||
const [itemWidth, setItemWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
@@ -103,7 +109,13 @@ export default function VirtualFeed({
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(m);
|
||||
}
|
||||
setColCount(columnsFor(view, node.clientWidth));
|
||||
const w = node.clientWidth;
|
||||
const cols = columnsFor(view, w);
|
||||
setColCount(cols);
|
||||
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
|
||||
setItemWidth(
|
||||
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
@@ -168,6 +180,7 @@ export default function VirtualFeed({
|
||||
key={v.id}
|
||||
video={v}
|
||||
view={view}
|
||||
width={itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpenChannel={onOpenChannel}
|
||||
|
||||
Reference in New Issue
Block a user