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

2906
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,11 @@ model PortfolioImage {
altText String? altText String?
description String? description String?
month Int? month Int?
sortKey Int?
year Int? year Int?
okLabL Float?
okLabA Float?
okLabB Float?
creationDate DateTime? creationDate DateTime?
albumId String? albumId String?

View File

@ -0,0 +1,219 @@
// "use server";
// import type { Prisma } from "@/generated/prisma"; // <-- types, no "any"
// import prisma from "@/lib/prisma";
// export type Cursor = { afterSortKey: number; afterId: string } | null;
// export type GalleryItem = {
// id: string;
// url: string;
// altText: string;
// backgroundColor: string;
// width: number;
// height: number;
// fullUrl: string;
// fullWidth: number;
// fullHeight: number;
// sortKey: number;
// fileKey: string;
// };
// export async function getPortfolioImagesPage(args: {
// take?: number;
// cursor?: Cursor;
// albumId?: string | null;
// year?: number | string | null;
// typeSlug?: string | null;
// onlyPublished?: boolean;
// }): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
// const {
// take = 60,
// cursor = null,
// albumId = null,
// year = null,
// typeSlug = null,
// onlyPublished = true,
// } = args;
// const coercedYear =
// typeof year === "string" && year.trim() !== "" ? Number(year) :
// typeof year === "number" ? year : undefined;
// const where: Prisma.PortfolioImageWhereInput = {
// sortKey: { not: null },
// variants: { some: { type: "resized" } },
// ...(onlyPublished ? { published: true } : {}),
// ...(albumId ? { albumId } : {}),
// ...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
// ...(typeSlug ? { type: { is: { slug: typeSlug } } } : {}),
// };
// if (cursor) {
// const sk = Number(cursor.afterSortKey);
// (where as Prisma.PortfolioImageWhereInput).OR = [
// { sortKey: { gt: sk } },
// { AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
// ];
// }
// const rows = await prisma.portfolioImage.findMany({
// where,
// orderBy: [{ sortKey: "asc" }, { id: "asc" }],
// take: Math.min(take, 200),
// select: {
// id: true,
// fileKey: true,
// altText: true,
// name: true,
// sortKey: true,
// variants: {
// select: { type: true, url: true, width: true, height: true },
// where: { type: { in: ["resized", "modified"] } },
// },
// colors: {
// where: { type: "Vibrant" },
// select: { color: { select: { hex: true } } },
// take: 1,
// },
// },
// });
// const items: GalleryItem[] = rows
// .map((r): GalleryItem | null => {
// const resized = r.variants.find(v => v.type === "resized");
// if (!resized?.width || !resized.height) return null;
// const full = r.variants.find(v => v.type === "modified");
// const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
// return {
// id: r.id,
// fileKey: r.fileKey,
// altText: r.altText ?? r.name ?? "",
// backgroundColor: bg,
// width: resized.width,
// height: resized.height,
// // use your existing API route for images:
// url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
// fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
// fullWidth: full?.width ?? 0,
// fullHeight: full?.height ?? 0,
// sortKey: r.sortKey ?? 0,
// };
// })
// .filter((x): x is GalleryItem => x !== null);
// const last = rows[rows.length - 1];
// const nextCursor: Cursor =
// rows.length < Math.min(take, 200) || !last
// ? null
// : { afterSortKey: (last.sortKey as number), afterId: last.id };
// return { items, nextCursor };
// }
"use server";
import prisma from "@/lib/prisma";
export type Cursor = { afterSortKey: number; afterId: string } | null;
export type GalleryItem = {
id: string;
url: string;
altText: string;
backgroundColor: string;
width: number;
height: number;
fullUrl: string;
fullWidth: number;
fullHeight: number;
sortKey: number;
fileKey: string;
};
type FindManyArgs = Parameters<typeof prisma.portfolioImage.findMany>[0];
type WhereInput = NonNullable<FindManyArgs>["where"];
export async function getPortfolioImagesPage(args: {
take?: number;
cursor?: Cursor;
albumId?: string | null;
year?: number | string | null;
}): Promise<{ items: GalleryItem[]; nextCursor: Cursor }> {
const { take = 60, cursor = null, albumId = null, year = null } = args;
const coercedYear =
typeof year === "string" && year.trim() !== "" ? Number(year) :
typeof year === "number" ? year : undefined;
const where: WhereInput = {
sortKey: { not: null },
published: true,
variants: { some: { type: "resized" } },
...(albumId ? { albumId } : {}),
...(coercedYear !== undefined && !Number.isNaN(coercedYear) ? { year: coercedYear } : {}),
};
if (cursor) {
const sk = Number(cursor.afterSortKey);
(where as WhereInput).OR = [
{ sortKey: { gt: sk } },
{ AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
];
}
const rows = await prisma.portfolioImage.findMany({
where,
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
take: Math.min(take, 200),
select: {
id: true,
fileKey: true,
altText: true,
name: true,
sortKey: true,
variants: {
select: { type: true, url: true, width: true, height: true },
where: { type: { in: ["resized", "modified"] } },
},
colors: {
where: { type: "Vibrant" },
select: { color: { select: { hex: true } } },
take: 1,
},
},
});
const items: GalleryItem[] = rows
.map((r): GalleryItem | null => {
const resized = r.variants.find(v => v.type === "resized");
if (!resized?.width || !resized.height) return null;
const full = r.variants.find(v => v.type === "modified");
const bg = r.colors[0]?.color?.hex ?? "#e5e7eb";
return {
id: r.id,
fileKey: r.fileKey,
altText: r.altText ?? r.name ?? "",
backgroundColor: bg,
width: resized.width,
height: resized.height,
// use your existing API route
url: resized.url ?? `/api/image/resized/${r.fileKey}.webp`,
fullUrl: full?.url ?? `/api/image/modified/${r.fileKey}.webp`,
fullWidth: full?.width ?? 0,
fullHeight: full?.height ?? 0,
sortKey: r.sortKey ?? 0,
};
})
.filter((x): x is GalleryItem => x !== null);
const last = rows[rows.length - 1];
const nextCursor: Cursor =
rows.length < Math.min(take, 200) || !last
? null
: { afterSortKey: (last.sortKey as number), afterId: last.id };
return { items, nextCursor };
}

View File

@ -0,0 +1,15 @@
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages";
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery";
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
const { year, album } = await searchParams;
const images = await getJustifiedImages(year, album, "art", false);
if (!images) return null
return (
<main className="p-2 mx-auto max-w-screen-2xl">
<JustifiedGallery images={images} rowHeight={340} gap={12} />
</main>
);
}

View File

@ -1,15 +1,19 @@
import { getJustifiedImages } from "@/actions/portfolio/getJustifiedImages"; import JustifiedGalleryV2 from "@/components/portfolio/JustifiedGalleryV2";
import { JustifiedGallery } from "@/components/portfolio/JustifiedGallery"; import prisma from "@/lib/prisma";
export default async function PortfolioArtworksPage({ searchParams }: { searchParams: { year?: string; album?: string } }) { export default async function ArtPage({ searchParams }: { searchParams: { year?: string; album?: string } }) {
const { year, album } = await searchParams; const { year, album } = await searchParams;
const images = await getJustifiedImages(year, album, "art", false); const images = await prisma.portfolioImage.findMany({
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
})
if (!images) return null if (!images) return null
// console.log(images)
return ( return (
<main className="p-2 mx-auto max-w-screen-2xl"> <main className="p-2 mx-auto max-w-screen-2xl">
<JustifiedGallery images={images} rowHeight={340} gap={12} /> <JustifiedGalleryV2 albumId={album} year={year} />
</main> </main>
); );
} }

View File

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

View File

@ -1,5 +1,5 @@
export default function Footer() { export default function Footer() {
return ( 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}
/>
)}
</>
);
}

View File

@ -0,0 +1,58 @@
export type JLItem = { id: string; w: number; h: number }; // original size
export type JLBox = JLItem & { renderW: number; renderH: number, row: number };
export function justifiedLayout(
items: JLItem[],
containerWidth: number,
targetRowH = 260,
gap = 8,
maxRowScale = 1.25 // how tall a row may scale vs target
): JLBox[] {
const out: JLBox[] = [];
if (containerWidth <= 0) return out;
let row: JLItem[] = [];
let rowAspectSum = 0;
let rowIndex = 0;
const flushRow = (isLast: boolean) => {
if (row.length === 0) return;
const gaps = gap * (row.length - 1);
const rawH = containerWidth / rowAspectSum; // height that would exactly fit the width
// For the very last row, keep it near target height to avoid huge upscaling
const targetH = isLast ? Math.min(targetRowH, rawH) : rawH;
const clampedH = Math.min(targetH, targetRowH * maxRowScale);
let used = 0;
row.forEach((it, i) => {
const ar = it.w / it.h;
let w = Math.round(clampedH * ar);
// Put gap between items; last item stretches to fix rounding drift
if (i === row.length - 1) w = containerWidth - used - gaps;
out.push({ ...it, renderW: w, renderH: Math.round(clampedH), row: rowIndex });
used += w + (i < row.length - 1 ? gap : 0);
});
row = [];
rowAspectSum = 0;
rowIndex += 1;
};
for (let i = 0; i < items.length; i++) {
const it = items[i];
const ar = it.w / it.h;
const testAspectSum = rowAspectSum + ar;
const rawH = containerWidth / testAspectSum;
// When adding this item would push row height below target, close the row
if (row.length > 0 && rawH < targetRowH) {
flushRow(false);
}
row.push(it);
rowAspectSum += ar;
}
flushRow(true); // last row
return out;
}