Fix responsive layout with max items per row for each breakpoint

This commit is contained in:
2026-02-02 00:05:13 +01:00
parent b559b8250f
commit 874aa5f343
2 changed files with 96 additions and 46 deletions

View File

@ -44,6 +44,7 @@ type Props = {
maxRowItems?: number; // desktop
maxRowItemsMobile?: number; // <640px
gap?: number; // px
debug?: boolean;
className?: string;
};
@ -84,6 +85,7 @@ export default function JustifiedGallery({
maxRowItems = 5,
maxRowItemsMobile = 3,
gap = 12,
debug = false,
className,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
@ -125,7 +127,6 @@ export default function JustifiedGallery({
const isMobile = containerWidth < 640;
const targetH = isMobile ? targetRowHeightMobile : targetRowHeight;
const minRowHeight = Math.round(targetH * 0.8);
const maxItems = (() => {
if (containerWidth < 480) return Math.min(2, maxRowItemsMobile);
if (containerWidth < 720) return Math.min(3, maxRowItems);
@ -161,7 +162,10 @@ export default function JustifiedGallery({
aspectSum = 0;
};
for (const it of items) {
const workingItems = items.slice();
for (let i = 0; i < workingItems.length; i += 1) {
const it = workingItems[i];
const a = aspectOf(it);
current.push({ item: it, aspect: a });
@ -170,29 +174,51 @@ export default function JustifiedGallery({
// Estimate the row width if we were to keep targetH
const estimatedWidth = aspectSum * targetH + gap * (current.length - 1);
// If the computed height would be too small, split the row earlier.
if (current.length > 1) {
const gaps = gap * (current.length - 1);
const widthWithoutGaps = Math.max(0, available - gaps);
const computedH = widthWithoutGaps / aspectSum;
if (computedH < minRowHeight) {
const last = current.pop();
if (last) {
aspectSum -= last.aspect;
flush();
current = [last];
aspectSum = last.aspect;
}
continue;
}
}
// If we've filled the row (or reached max items) and have at least 2 tiles, flush.
if (
(estimatedWidth >= available || current.length >= maxItems) &&
current.length > 1
) {
flush();
const gaps = gap * (current.length - 1);
const widthWithoutGaps = Math.max(0, available - gaps);
const computedH = widthWithoutGaps / aspectSum;
// If the row would be shorter than maxRowHeight, reduce items and flush.
if (computedH < maxRowHeight && current.length > 1) {
const last = current.pop();
if (last) {
aspectSum -= last.aspect;
}
const limit = widthWithoutGaps / maxRowHeight;
let swapped = false;
for (let look = 1; look <= 2; look += 1) {
const idx = i + look;
if (idx >= workingItems.length) break;
const candidate = workingItems[idx];
const candidateAspect = aspectOf(candidate);
if (aspectSum + candidateAspect <= limit) {
workingItems[idx] = last?.item ?? candidate;
current.push({ item: candidate, aspect: candidateAspect });
aspectSum += candidateAspect;
swapped = true;
break;
}
}
flush();
if (!swapped && last) {
current = [last];
aspectSum = last.aspect;
} else {
current = [];
aspectSum = 0;
}
} else {
flush();
}
}
}
@ -215,27 +241,50 @@ export default function JustifiedGallery({
return `${first}-${last}-${row.length}`;
}, []);
const isSmallScreen = containerWidth < 640;
return (
<div
ref={containerRef}
className={cn("mx-auto w-full max-w-6xl", className)}
>
<div className="space-y-3">
{rows.map((row) => (
<div
key={getRowKey(row)}
className="flex justify-center"
style={{ gap }}
>
{row.map((t) => (
<GalleryTile
key={t.item.id}
tile={t}
hrefBase={hrefBase}
hrefFrom={hrefFrom}
showCaption={showCaption}
/>
))}
{rows.map((row, idx) => (
<div key={getRowKey(row)}>
<div
className={cn(
"flex",
row.length === 1 && (isSmallScreen || idx !== rows.length - 1)
? "justify-center"
: idx === rows.length - 1
? "justify-start"
: "justify-between",
)}
style={{ columnGap: gap }}
>
{row.map((t) => (
<GalleryTile
key={t.item.id}
tile={t}
hrefBase={hrefBase}
hrefFrom={hrefFrom}
showCaption={showCaption}
/>
))}
</div>
{debug ? (
<div className="text-xs text-muted-foreground font-mono">
{`row ${idx + 1} | h=${Math.round(row[0]?.h ?? 0)} | w=${Math.round(
row.reduce((sum, t) => sum + t.w, 0) + gap * (row.length - 1),
)} | items=${row.length} | `}
{row
.map(
(t) =>
`${t.item.id}:${Math.round(t.w)}x${Math.round(t.h)} (src ${t.item.width}x${t.item.height})`,
)
.join(" | ")}
</div>
) : null}
</div>
))}
</div>

View File

@ -90,17 +90,17 @@ export default function PortfolioGallery({
dominantHex: it.dominantHex,
}));
useEffect(() => {
if (items.length === 0) return;
// Debug: inspect dominantHex values coming from the server.
console.log(
"[PortfolioGallery] dominantHex sample",
items.slice(0, 5).map((it) => ({
id: it.id,
dominantHex: it.dominantHex,
}))
);
}, [items]);
// useEffect(() => {
// if (items.length === 0) return;
// // Debug: inspect dominantHex values coming from the server.
// console.log(
// "[PortfolioGallery] dominantHex sample",
// items.slice(0, 5).map((it) => ({
// id: it.id,
// dominantHex: it.dominantHex,
// }))
// );
// }, [items]);
if (!loading && done && galleryItems.length === 0) {
return (
@ -116,6 +116,7 @@ export default function PortfolioGallery({
items={galleryItems}
hrefFrom="portfolio"
showCaption={false}
debug={false}
targetRowHeight={160}
targetRowHeightMobile={160}
maxRowHeight={300}