Changes to image sort

This commit is contained in:
2025-10-29 09:35:25 +01:00
parent a404f9b2d3
commit 8882449588
10 changed files with 2125 additions and 1370 deletions

View File

@ -1,6 +1,10 @@
import prisma from "@/lib/prisma";
import { cn } from "@/lib/utils";
import { Pacifico } from "next/font/google";
import Image from "next/image";
const pacifico = Pacifico({ weight: "400", subsets: ["latin"] });
export default async function Banner() {
const headerImage = await prisma.portfolioImage.findFirst({
where: { setAsHeader: true },
@ -28,7 +32,7 @@ export default async function Banner() {
/>
{/* Overlay Logo / Title */}
<div className="absolute inset-0 bg-black/40 flex items-center justify-center text-center">
<h1 className="text-white text-3xl md:text-5xl font-bold drop-shadow">
<h1 className={cn(pacifico.className, "text-shadow text-white text-3xl md:text-5xl font-bold drop-shadow")}>
{title}
</h1>
</div>

View File

@ -1,5 +1,5 @@
export default function Footer() {
return (
<div>Footer</div>
<div>© 2025 by gaertan.art | All rights reserved</div>
);
}

View File

@ -0,0 +1,269 @@
"use client";
import { Cursor, GalleryItem, getPortfolioImagesPage } from "@/actions/portfolio/getPortfolioImagesPage";
import * as React from "react";
import { ImageCard } from "./ImageCard";
import { Lightbox } from "./Lightbox";
type JLItem = { id: string; w: number; h: number };
type JLBox = JLItem & { renderW: number; renderH: number; row: number };
type FillMode = "ragged" | "distribute";
function justifiedLayout(
items: JLItem[],
containerWidth: number,
targetRowH = 260,
gap = 8,
maxRowScale = 1.25,
fillMode: FillMode = "ragged", // <— new
): JLBox[] {
const out: JLBox[] = [];
if (containerWidth <= 0) return out;
let row: JLItem[] = [];
let sumAspect = 0;
let rowIndex = 0;
const flush = (isLast: boolean) => {
if (!row.length) return;
const gapsTotal = gap * (row.length - 1);
// Height that would exactly fit if we justified
const rawH = (containerWidth - gapsTotal) / sumAspect;
// Normal case: set row height close to target, but never exceed maxRowScale
const baseH = Math.min(
isLast ? Math.min(targetRowH, rawH) : rawH,
targetRowH * maxRowScale
);
const exact = row.map(it => baseH * (it.w / it.h));
if (fillMode === "distribute" && !isLast) {
// Fill the row exactly by distributing rounding error fairly
const floor = exact.map(Math.floor);
const used = floor.reduce((a, b) => a + b, 0) + gap * (row.length - 1);
let remaining = Math.max(0, containerWidth - used);
const fracs = exact.map((v, i) => ({ i, frac: v - floor[i] }));
fracs.sort((a, b) => b.frac - a.frac);
const widths = floor.slice();
for (let k = 0; k < widths.length && remaining > 0; k++) {
widths[fracs[k].i] += 1;
remaining--;
}
widths[widths.length - 1] += remaining; // just in case
widths.forEach((w, i) => {
out.push({ ...row[i], renderW: w, renderH: Math.round(baseH), row: rowIndex });
});
} else {
// RAGGED: do not force the row to fill the width
exact.forEach((w, i) => {
out.push({ ...row[i], renderW: Math.round(w), renderH: Math.round(baseH), row: rowIndex });
});
}
row = [];
sumAspect = 0;
rowIndex++;
};
for (let i = 0; i < items.length; i++) {
const it = items[i];
const ar = it.w / it.h;
const testSum = sumAspect + ar;
const wouldH = (containerWidth - gap * (row.length)) / testSum;
// If adding this item would drop row height below target, flush current row
if (row.length > 0 && wouldH < targetRowH) flush(false);
row.push(it);
sumAspect += ar;
}
flush(true); // last row (never fully justified in ragged mode)
return out;
}
export default function JustifiedGallery({
albumId = null,
year = null,
}: {
albumId?: string | null;
year?: string | number | null;
}) {
const [items, setItems] = React.useState<GalleryItem[]>([]);
const [cursor, setCursor] = React.useState<Cursor>(null);
const [done, setDone] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
const inFlight = React.useRef(false);
const doneRef = React.useRef(false);
doneRef.current = done;
const containerRef = React.useRef<HTMLDivElement>(null);
const [containerW, setContainerW] = React.useState(0);
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setContainerW(Math.floor(e.contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
const GAP = 16;
const ROW_HEIGHT = 340;
const sentinelRef = React.useRef<HTMLDivElement>(null);
const ioRef = React.useRef<IntersectionObserver | null>(null);
const loadMore = React.useCallback(async () => {
if (inFlight.current || doneRef.current) return 0;
inFlight.current = true;
setLoading(true);
const sentinel = sentinelRef.current;
if (sentinel && ioRef.current) ioRef.current.unobserve(sentinel);
try {
const data = await getPortfolioImagesPage({ take: 60, cursor, albumId, year });
setItems(prev => prev.concat(data.items));
setCursor(data.nextCursor);
if (!data.nextCursor) setDone(true);
return data.items.length;
} finally {
setLoading(false);
inFlight.current = false;
if (!doneRef.current && sentinel && ioRef.current) ioRef.current.observe(sentinel);
}
}, [cursor, albumId, year]);
// initial + when filters change
React.useEffect(() => {
setItems([]); setCursor(null); setDone(false); doneRef.current = false; inFlight.current = false;
void loadMore();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [albumId, year]);
// IO wiring
React.useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
ioRef.current?.disconnect();
ioRef.current = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) {
ioRef.current?.unobserve(sentinel);
void loadMore();
}
}, { rootMargin: "600px 0px", threshold: 0.01 });
ioRef.current.observe(sentinel);
return () => ioRef.current?.disconnect();
}, [loadMore]);
// layout boxes
const boxes = React.useMemo(() => {
const base: JLItem[] = items.map(i => ({ id: i.id, w: i.width, h: i.height }));
return justifiedLayout(base, containerW, ROW_HEIGHT, GAP, 1.25, 'distribute');
}, [items, containerW, ROW_HEIGHT, GAP]);
// quick lookups
const idToIndex = React.useMemo(() => new Map(items.map((it, idx) => [it.id, idx])), [items]);
const idToMeta = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
// open lightbox at the global (items) index
const onTileClick = React.useCallback((id: string) => {
const idx = idToIndex.get(id);
if (idx !== undefined) setSelectedIndex(idx);
}, [idToIndex]);
// modal navigation
const hasPrev = selectedIndex !== null && selectedIndex > 0;
const hasNextLoaded = selectedIndex !== null && selectedIndex < items.length - 1;
const hasNextPotential = !done; // if not done, we might be able to load more
const onPrev = React.useCallback(() => {
setSelectedIndex(i => (i !== null && i > 0 ? i - 1 : i));
}, []);
const onNext = React.useCallback(async () => {
// Use a snapshot of the current selected index
const current = selectedIndex;
if (current === null) return;
// If next image is already loaded
if (current < items.length - 1) {
setSelectedIndex(current + 1);
return;
}
// If we're at the end but more pages exist
if (!doneRef.current) {
const added = await loadMore();
if (added > 0) {
setSelectedIndex(current + 1);
return;
}
}
// No more images
}, [selectedIndex, items.length, loadMore]);
return (
<>
<div ref={containerRef} className="w-full flex flex-col" style={{ rowGap: `${GAP}px` }}>
{boxes.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-20">No images to display</p>
)}
{/* render by rows */}
{(() => {
const rows: Record<number, JLBox[]> = {};
for (const b of boxes) (rows[b.row] ??= []).push(b);
return Object.keys(rows).map((rowKey) => {
const row = rows[Number(rowKey)];
return (
<div key={rowKey} className="flex" style={{ columnGap: `${GAP}px` }}>
{row.map((b) => {
const meta = idToMeta.get(b.id)!;
return (
<div
key={b.id}
style={{ width: b.renderW, height: b.renderH }}
onClick={() => onTileClick(b.id)}
className="cursor-zoom-in"
>
<ImageCard
image={{
id: meta.id,
altText: meta.altText,
url: meta.url,
backgroundColor: meta.backgroundColor,
}}
variant="mosaic"
/>
</div>
);
})}
</div>
);
});
})()}
{!done && <div ref={sentinelRef} style={{ height: 1 }} />}
{loading && <p className="text-sm text-muted-foreground mt-2">Loading</p>}
</div>
{/* Lightbox modal */}
{selectedIndex !== null && (
<Lightbox
image={items[selectedIndex]}
onClose={() => setSelectedIndex(null)}
hasPrev={hasPrev}
hasNext={hasNextLoaded || hasNextPotential}
onPrev={onPrev}
onNext={onNext}
/>
)}
</>
);
}