Add portfolio thingies
This commit is contained in:
226
src/components/portfolio/ColorMasonryGallery.tsx
Normal file
226
src/components/portfolio/ColorMasonryGallery.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
Cursor,
|
||||
PortfolioArtworkItem,
|
||||
PortfolioFilters,
|
||||
} from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
import { getPortfolioArtworksPage } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||
|
||||
type Placement = {
|
||||
id: string;
|
||||
top: number;
|
||||
left: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dominantHex: string;
|
||||
};
|
||||
|
||||
function computeCols(containerW: number, gap: number, minColW: number, maxCols: number) {
|
||||
const cols = Math.max(1, Math.min(maxCols, Math.floor((containerW + gap) / (minColW + gap))));
|
||||
const colW = Math.floor((containerW - gap * (cols - 1)) / cols);
|
||||
return { cols, colW: Math.max(1, colW) };
|
||||
}
|
||||
|
||||
function packStableMasonry(
|
||||
items: PortfolioArtworkItem[],
|
||||
containerW: number,
|
||||
opts: { gap: number; minColW: number; maxCols: number }
|
||||
): { placements: Placement[]; height: number } {
|
||||
const { gap, minColW, maxCols } = opts;
|
||||
if (containerW <= 0 || items.length === 0) return { placements: [], height: 0 };
|
||||
|
||||
const { cols, colW } = computeCols(containerW, gap, minColW, maxCols);
|
||||
const colHeights = Array(cols).fill(0) as number[];
|
||||
const placements: Placement[] = [];
|
||||
|
||||
for (const it of items) {
|
||||
let cBest = 0;
|
||||
for (let c = 1; c < cols; c++) if (colHeights[c] < colHeights[cBest]) cBest = c;
|
||||
|
||||
const ratio = it.thumbH / it.thumbW;
|
||||
const h = Math.round(colW * ratio);
|
||||
|
||||
const top = colHeights[cBest];
|
||||
const left = cBest * (colW + gap);
|
||||
|
||||
placements.push({
|
||||
id: it.id,
|
||||
top,
|
||||
left,
|
||||
w: colW,
|
||||
h,
|
||||
dominantHex: it.dominantHex,
|
||||
});
|
||||
|
||||
colHeights[cBest] = top + h + gap;
|
||||
}
|
||||
|
||||
const height = Math.max(...colHeights) - gap;
|
||||
return { placements, height: Math.max(0, height) };
|
||||
}
|
||||
|
||||
function thumbUrl(fileKey: string) {
|
||||
return `/api/image/thumbnail/${fileKey}.webp`;
|
||||
}
|
||||
|
||||
function useResizeObserverWidth() {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [w, setW] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([e]) => setW(Math.floor(e.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, w };
|
||||
}
|
||||
|
||||
export default function ColorMasonryGallery({
|
||||
initialFilters,
|
||||
}: {
|
||||
initialFilters?: PortfolioFilters;
|
||||
}) {
|
||||
const { ref: containerRef, w: containerW } = useResizeObserverWidth();
|
||||
|
||||
const [filters, setFilters] = React.useState<PortfolioFilters>(initialFilters ?? {});
|
||||
const [items, setItems] = React.useState<PortfolioArtworkItem[]>([]);
|
||||
const [cursor, setCursor] = React.useState<Cursor>(null);
|
||||
const [done, setDone] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const inFlight = React.useRef(false);
|
||||
const doneRef = React.useRef(false);
|
||||
doneRef.current = done;
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems([]);
|
||||
setCursor(null);
|
||||
setDone(false);
|
||||
doneRef.current = false;
|
||||
inFlight.current = false;
|
||||
}, [filters]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (inFlight.current || doneRef.current) return 0;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const data = await getPortfolioArtworksPage({
|
||||
take: 80,
|
||||
cursor,
|
||||
filters,
|
||||
onlyPublished: false,
|
||||
});
|
||||
|
||||
// Defensive dedupe: prevents accidental repeats from any future cursor edge case
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((x) => x.id));
|
||||
const next = data.items.filter((x) => !seen.has(x.id));
|
||||
return prev.concat(next);
|
||||
});
|
||||
|
||||
setCursor(data.nextCursor);
|
||||
if (!data.nextCursor) setDone(true);
|
||||
return data.items.length;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [cursor, filters]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]);
|
||||
|
||||
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) void loadMore();
|
||||
},
|
||||
{ rootMargin: "900px 0px", threshold: 0.01 }
|
||||
);
|
||||
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
const GAP = 14;
|
||||
const MIN_COL_W = 260;
|
||||
const MAX_COLS = 6;
|
||||
|
||||
const { placements, height } = React.useMemo(() => {
|
||||
return packStableMasonry(items, containerW, {
|
||||
gap: GAP,
|
||||
minColW: MIN_COL_W,
|
||||
maxCols: MAX_COLS,
|
||||
});
|
||||
}, [items, containerW]);
|
||||
|
||||
const itemsById = React.useMemo(() => new Map(items.map((it) => [it.id, it])), [items]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full">
|
||||
<div className="relative w-full" style={{ height }}>
|
||||
{placements.map((p) => {
|
||||
const it = itemsById.get(p.id);
|
||||
if (!it) return null;
|
||||
|
||||
const href = `/artworks/single/${it.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
transform: `translate(${p.left}px, ${p.top}px)`,
|
||||
width: p.w,
|
||||
height: p.h,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={[
|
||||
"block w-full h-full overflow-hidden rounded-md",
|
||||
"border border-transparent",
|
||||
"transition-colors duration-150",
|
||||
"hover:border-[color:var(--dom)]",
|
||||
].join(" ")}
|
||||
style={{ ["--dom" as any]: p.dominantHex }}
|
||||
aria-label={`Open ${it.name}`}
|
||||
>
|
||||
<Image
|
||||
src={thumbUrl(it.fileKey)}
|
||||
alt={it.altText ?? it.name}
|
||||
width={Math.max(1, it.thumbW)}
|
||||
height={Math.max(1, it.thumbH)}
|
||||
className="w-full h-full object-cover select-none"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!done && <div ref={sentinelRef} style={{ height: 1 }} />}
|
||||
{loading && <p className="text-sm text-muted-foreground mt-3">Loading…</p>}
|
||||
{!loading && done && items.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-20">No artworks to display</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user