diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bac4c71..b117bf6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,10 @@ model Artwork { published Boolean @default(false) setAsHeader Boolean @default(false) + colorStatus String @default("PENDING") // PENDING | PROCESSING | READY | FAILED + colorError String? + colorsGeneratedAt DateTime? + fileId String @unique file FileData @relation(fields: [fileId], references: [id]) @@ -50,6 +54,10 @@ model Artwork { colors ArtworkColor[] tags ArtTag[] variants FileVariant[] + + @@index([colorStatus]) + @@index([published, sortKey, id]) + @@index([year, published, sortKey, id]) } model Album { diff --git a/src/actions/portfolio/getPortfolioArtworksPage.ts b/src/actions/portfolio/getPortfolioArtworksPage.ts new file mode 100644 index 0000000..cb67794 --- /dev/null +++ b/src/actions/portfolio/getPortfolioArtworksPage.ts @@ -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 }; +} diff --git a/src/app/(normal)/artworks/page.tsx b/src/app/(normal)/artworks/page.tsx new file mode 100644 index 0000000..c698304 --- /dev/null +++ b/src/app/(normal)/artworks/page.tsx @@ -0,0 +1,14 @@ +import ColorMasonryGallery from "@/components/portfolio/ColorMasonryGallery"; + +export default function PortfolioPage() { + return ( +
+
+

Portfolio

+

Browse artworks ordered by color.

+
+ + +
+ ); +} diff --git a/src/components/portfolio/ColorMasonryGallery.tsx b/src/components/portfolio/ColorMasonryGallery.tsx new file mode 100644 index 0000000..ca3cad1 --- /dev/null +++ b/src/components/portfolio/ColorMasonryGallery.tsx @@ -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(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(initialFilters ?? {}); + const [items, setItems] = React.useState([]); + const [cursor, setCursor] = React.useState(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(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 ( +
+
+ {placements.map((p) => { + const it = itemsById.get(p.id); + if (!it) return null; + + const href = `/artworks/single/${it.id}`; + + return ( +
+ + {it.altText + +
+ ); + })} +
+ + {!done &&
} + {loading &&

Loading…

} + {!loading && done && items.length === 0 && ( +

No artworks to display

+ )} +
+ ); +}