Hide orphan trailing row when more pages are loading, show skeleton instead

When hasNextPage is true and the last row is incomplete (not fully packed),
hide it to prevent oversized straggler thumbnails. Show 2 skeleton rows as
a loading placeholder. Once the next page loads, those items get properly
packed with new neighbors.
This commit is contained in:
Alex Gleason
2026-03-13 14:21:43 -05:00
parent 932483049e
commit 641c85ed56
+36 -5
View File
@@ -56,6 +56,12 @@ interface JustifiedRow<T> {
heightFraction: number;
}
interface JustifiedLayoutResult<T> {
rows: JustifiedRow<T>[];
/** True when the last row was not fully packed (trailing/orphan row). */
lastRowIncomplete: boolean;
}
/**
* Compute a justified (Google Photosstyle) row layout.
* Packs items into rows so each row fills the container width.
@@ -71,8 +77,8 @@ function justifiedLayout<T>(
getAspectRatio: (item: T) => number,
targetRowHeight: number = 0.3,
maxRowItems: number = 5,
): JustifiedRow<T>[] {
if (items.length === 0) return [];
): JustifiedLayoutResult<T> {
if (items.length === 0) return { rows: [], lastRowIncomplete: false };
const rows: JustifiedRow<T>[] = [];
let currentRow: T[] = [];
@@ -102,9 +108,10 @@ function justifiedLayout<T>(
items: currentRow,
heightFraction: Math.min(rowHeightFraction, targetRowHeight),
});
return { rows, lastRowIncomplete: true };
}
return rows;
return { rows, lastRowIncomplete: false };
}
// ── Media extraction ──────────────────────────────────────────────────────────
@@ -391,7 +398,7 @@ export function MediaGrid({ events, className, initialOpenUrl, onInitialOpenCons
}, [items]);
// Compute justified row layout — fewer items per row on mobile for larger thumbnails
const rows = useMemo(
const { rows, lastRowIncomplete } = useMemo(
() => justifiedLayout(
items.map((item, i) => ({ item, index: i })),
({ item }) => parseDimToAspectRatio(item.dim),
@@ -401,6 +408,10 @@ export function MediaGrid({ events, className, initialOpenUrl, onInitialOpenCons
[items, isMobile],
);
// When more pages are coming, hide the trailing incomplete row to avoid
// oversized orphan thumbnails. Show a skeleton placeholder instead.
const visibleRows = hasNextPage && lastRowIncomplete ? rows.slice(0, -1) : rows;
// Open at initialOpenUrl if provided
const initialIndex = useMemo(() => {
if (!initialOpenUrl) return null;
@@ -463,7 +474,7 @@ export function MediaGrid({ events, className, initialOpenUrl, onInitialOpenCons
return (
<>
<div className={cn('flex flex-col gap-1.5 p-1.5', className)}>
{rows.map((row, rowIdx) => {
{visibleRows.map((row, rowIdx) => {
// The row's aspect ratio is the sum of all item aspect ratios
// (at equal height, total width = sum of ARs * height)
const rowAR = row.items.reduce((s, { item }) => s + parseDimToAspectRatio(item.dim), 0);
@@ -494,6 +505,26 @@ export function MediaGrid({ events, className, initialOpenUrl, onInitialOpenCons
</div>
);
})}
{/* Skeleton placeholder while next page loads */}
{hasNextPage && (
<>
{(isMobile ? SKELETON_ROWS_MOBILE : SKELETON_ROWS_DESKTOP).slice(0, 2).map((ratios, i) => {
const rowAR = ratios.reduce((s, r) => s + r, 0);
return (
<div key={`skel-${i}`} className="flex gap-1.5" style={{ aspectRatio: `${rowAR}` }}>
{ratios.map((ar, j) => (
<Skeleton
key={j}
className="rounded-lg h-full animate-pulse"
style={{ flexGrow: ar, flexBasis: 0 }}
/>
))}
</div>
);
})}
</>
)}
</div>
{flatIndex !== null && (