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
|
// 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
|
// 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.
|
// (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();
|
const { t } = useTranslation();
|
||||||
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
|
// 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
|
// 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
|
// 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.
|
// the text-size setting the same way its contents do.
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
|
||||||
className={clsx(
|
|
||||||
"shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
|
{/* 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). */}
|
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
|
||||||
<span className="text-right tabular-nums">
|
<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({
|
function VideoCard({
|
||||||
video,
|
video,
|
||||||
view,
|
view,
|
||||||
|
width,
|
||||||
onState,
|
onState,
|
||||||
onResetState,
|
onResetState,
|
||||||
onToggleSave,
|
onToggleSave,
|
||||||
@@ -338,6 +344,8 @@ function VideoCard({
|
|||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
|
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
|
||||||
|
width: number;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => 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.
|
// The title takes what's left, which is constant per container width — so it aligns too.
|
||||||
if (noThumb) {
|
if (noThumb) {
|
||||||
const pct = resumePct(video);
|
const pct = resumePct(video);
|
||||||
|
const fits = (needs: number) => width === 0 || width >= needs;
|
||||||
return (
|
return (
|
||||||
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
|
<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
|
{/* 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>
|
<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
|
{/* 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
|
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
|
from the right as the row narrows, 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
|
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
|
||||||
rail and filter panel move the row's width too — but they're pitched to keep the title
|
filter panel are doing. Until the first measure lands (width 0) show everything: the
|
||||||
alive at the narrow end rather than to hold every column to the last pixel. */}
|
row is off-screen for that frame anyway, and guessing narrow would flash. */}
|
||||||
<div className="min-w-0 flex-1">{title}</div>
|
<div className="min-w-0 flex-1">{title}</div>
|
||||||
<div className="shrink-0 w-32 hidden sm:block">{channelButton}</div>
|
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
|
||||||
<RowMetaCells video={video} className="hidden md:grid" />
|
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
|
||||||
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
|
{/* 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
|
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. */}
|
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">
|
{fits(ROW_FITS.views) && (
|
||||||
{viewsCell(false)}
|
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
|
||||||
</div>
|
{viewsCell(false)}
|
||||||
<div className="shrink-0 w-48 text-sm text-muted truncate hidden xl:block">{published}</div>
|
</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. */}
|
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
|
||||||
{pct > 0 && (
|
{pct > 0 && (
|
||||||
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
|
<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
|
// Distance from the scroll element's content top to where this list starts (the
|
||||||
// feed toolbar sits above it inside the same scroll area).
|
// feed toolbar sits above it inside the same scroll area).
|
||||||
const [scrollMargin, setScrollMargin] = useState(0);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||||
@@ -103,7 +109,13 @@ export default function VirtualFeed({
|
|||||||
scrollEl.scrollTop;
|
scrollEl.scrollTop;
|
||||||
setScrollMargin(m);
|
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();
|
measure();
|
||||||
const ro = new ResizeObserver(measure);
|
const ro = new ResizeObserver(measure);
|
||||||
@@ -168,6 +180,7 @@ export default function VirtualFeed({
|
|||||||
key={v.id}
|
key={v.id}
|
||||||
video={v}
|
video={v}
|
||||||
view={view}
|
view={view}
|
||||||
|
width={itemWidth}
|
||||||
onState={onState}
|
onState={onState}
|
||||||
onToggleSave={onToggleSave}
|
onToggleSave={onToggleSave}
|
||||||
onOpenChannel={onOpenChannel}
|
onOpenChannel={onOpenChannel}
|
||||||
|
|||||||
Reference in New Issue
Block a user