Add portfolio thingies
This commit is contained in:
@ -37,6 +37,10 @@ model Artwork {
|
|||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
setAsHeader Boolean @default(false)
|
setAsHeader Boolean @default(false)
|
||||||
|
|
||||||
|
colorStatus String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
|
||||||
|
colorError String?
|
||||||
|
colorsGeneratedAt DateTime?
|
||||||
|
|
||||||
fileId String @unique
|
fileId String @unique
|
||||||
file FileData @relation(fields: [fileId], references: [id])
|
file FileData @relation(fields: [fileId], references: [id])
|
||||||
|
|
||||||
@ -50,6 +54,10 @@ model Artwork {
|
|||||||
colors ArtworkColor[]
|
colors ArtworkColor[]
|
||||||
tags ArtTag[]
|
tags ArtTag[]
|
||||||
variants FileVariant[]
|
variants FileVariant[]
|
||||||
|
|
||||||
|
@@index([colorStatus])
|
||||||
|
@@index([published, sortKey, id])
|
||||||
|
@@index([year, published, sortKey, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Album {
|
model Album {
|
||||||
|
|||||||
214
src/actions/portfolio/getPortfolioArtworksPage.ts
Normal file
214
src/actions/portfolio/getPortfolioArtworksPage.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { Prisma } from "@/generated/prisma/browser";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export type Cursor = {
|
||||||
|
afterSortKey: number | null;
|
||||||
|
afterId: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export type PortfolioArtworkItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
altText: string | null;
|
||||||
|
|
||||||
|
sortKey: number | null;
|
||||||
|
year: number | null;
|
||||||
|
|
||||||
|
fileKey: string;
|
||||||
|
|
||||||
|
thumbW: number;
|
||||||
|
thumbH: number;
|
||||||
|
|
||||||
|
dominantHex: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortfolioFilters = {
|
||||||
|
year?: number | null;
|
||||||
|
galleryId?: string | null;
|
||||||
|
albumId?: string | null;
|
||||||
|
tagIds?: string[];
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function coerceYear(year?: number | string | null) {
|
||||||
|
if (typeof year === "number") return Number.isFinite(year) ? year : null;
|
||||||
|
if (typeof year === "string") {
|
||||||
|
const t = year.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
const n = Number(t);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariantPick = { type: string; width: number; height: number };
|
||||||
|
function pickVariant(variants: VariantPick[], type: string) {
|
||||||
|
return variants.find((v) => v.type === type) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPortfolioArtworksPage(args: {
|
||||||
|
take?: number;
|
||||||
|
cursor?: Cursor;
|
||||||
|
filters?: PortfolioFilters;
|
||||||
|
onlyPublished?: boolean;
|
||||||
|
}): Promise<{ items: PortfolioArtworkItem[]; nextCursor: Cursor }> {
|
||||||
|
const { take = 60, cursor = null, filters = {}, onlyPublished = true } = args;
|
||||||
|
|
||||||
|
const year = coerceYear(filters.year ?? null);
|
||||||
|
|
||||||
|
const baseWhere: Prisma.ArtworkWhereInput = {
|
||||||
|
...(onlyPublished ? { published: true } : {}),
|
||||||
|
...(year != null ? { year } : {}),
|
||||||
|
...(filters.galleryId ? { galleryId: filters.galleryId } : {}),
|
||||||
|
...(filters.albumId ? { albums: { some: { id: filters.albumId } } } : {}),
|
||||||
|
|
||||||
|
...(filters.tagIds?.length
|
||||||
|
? { tags: { some: { id: { in: filters.tagIds } } } }
|
||||||
|
: {}),
|
||||||
|
|
||||||
|
...(filters.search?.trim()
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: filters.search.trim(), mode: "insensitive" } },
|
||||||
|
{ slug: { contains: filters.search.trim(), mode: "insensitive" } },
|
||||||
|
{ altText: { contains: filters.search.trim(), mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Require thumbnail variant for grid placement
|
||||||
|
const baseSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
altText: true,
|
||||||
|
year: true,
|
||||||
|
sortKey: true,
|
||||||
|
file: { select: { fileKey: true } },
|
||||||
|
variants: {
|
||||||
|
where: { type: { in: ["thumbnail"] } },
|
||||||
|
select: { type: true, width: true, height: true },
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
where: { type: "Vibrant" },
|
||||||
|
select: { color: { select: { hex: true } } },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
} satisfies Prisma.ArtworkSelect;
|
||||||
|
|
||||||
|
const mapRow = (r: any): PortfolioArtworkItem | null => {
|
||||||
|
const thumb = pickVariant(r.variants, "thumbnail");
|
||||||
|
if (!thumb?.width || !thumb?.height) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
slug: r.slug,
|
||||||
|
altText: r.altText ?? null,
|
||||||
|
sortKey: r.sortKey ?? null,
|
||||||
|
year: r.year ?? null,
|
||||||
|
fileKey: r.file.fileKey,
|
||||||
|
thumbW: thumb.width,
|
||||||
|
thumbH: thumb.height,
|
||||||
|
dominantHex: r.colors[0]?.color?.hex ?? "#999999",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Segment handling ---
|
||||||
|
// Segment A: sortKey != null orderBy sortKey asc, id asc
|
||||||
|
// Segment B: sortKey == null orderBy id asc
|
||||||
|
const inNullSegment = cursor?.afterSortKey === null;
|
||||||
|
|
||||||
|
// Query A (non-null segment) unless we're already in null segment
|
||||||
|
let items: PortfolioArtworkItem[] = [];
|
||||||
|
let nextCursor: Cursor = null;
|
||||||
|
|
||||||
|
if (!inNullSegment) {
|
||||||
|
const whereA: Prisma.ArtworkWhereInput = {
|
||||||
|
...baseWhere,
|
||||||
|
sortKey: { not: null },
|
||||||
|
variants: { some: { type: "thumbnail" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cursor?.afterSortKey != null) {
|
||||||
|
const sk = Number(cursor.afterSortKey);
|
||||||
|
whereA.OR = [
|
||||||
|
{ sortKey: { gt: sk } },
|
||||||
|
{ AND: [{ sortKey: sk }, { id: { gt: cursor.afterId } }] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsA = await prisma.artwork.findMany({
|
||||||
|
where: whereA,
|
||||||
|
orderBy: [{ sortKey: "asc" }, { id: "asc" }],
|
||||||
|
take: Math.min(take, 200),
|
||||||
|
select: baseSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
items = rowsA.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
|
|
||||||
|
// If we fully satisfied take within segment A, return cursor in A
|
||||||
|
if (items.length >= take) {
|
||||||
|
const last = items[items.length - 1]!;
|
||||||
|
nextCursor = { afterSortKey: last.sortKey!, afterId: last.id };
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we will transition into segment B and fill the remainder
|
||||||
|
const remaining = take - items.length;
|
||||||
|
|
||||||
|
const lastAId = items.length > 0 ? items[items.length - 1]!.id : null;
|
||||||
|
|
||||||
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
|
...baseWhere,
|
||||||
|
sortKey: null,
|
||||||
|
variants: { some: { type: "thumbnail" } },
|
||||||
|
...(lastAId ? { id: { gt: lastAId } } : {}), // IMPORTANT: don't restart from beginning during transition
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowsB = await prisma.artwork.findMany({
|
||||||
|
where: whereB,
|
||||||
|
orderBy: [{ id: "asc" }],
|
||||||
|
take: Math.min(remaining, 200),
|
||||||
|
select: baseSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const more = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
|
items = items.concat(more);
|
||||||
|
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
nextCursor =
|
||||||
|
items.length < take || !last
|
||||||
|
? null
|
||||||
|
: { afterSortKey: last.sortKey ?? null, afterId: last.id };
|
||||||
|
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query B (null segment continuation)
|
||||||
|
const whereB: Prisma.ArtworkWhereInput = {
|
||||||
|
...baseWhere,
|
||||||
|
sortKey: null,
|
||||||
|
variants: { some: { type: "thumbnail" } },
|
||||||
|
...(cursor ? { id: { gt: cursor.afterId } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowsB = await prisma.artwork.findMany({
|
||||||
|
where: whereB,
|
||||||
|
orderBy: [{ id: "asc" }],
|
||||||
|
take: Math.min(take, 200),
|
||||||
|
select: baseSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
items = rowsB.map(mapRow).filter((x): x is PortfolioArtworkItem => x !== null);
|
||||||
|
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
nextCursor =
|
||||||
|
items.length < take || !last ? null : { afterSortKey: null, afterId: last.id };
|
||||||
|
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
14
src/app/(normal)/artworks/page.tsx
Normal file
14
src/app/(normal)/artworks/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery";
|
||||||
|
|
||||||
|
export default function PortfolioPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Portfolio</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Browse artworks ordered by color.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColorMasonryGallery />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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