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 ( +
Browse artworks ordered by color.
+Loading…
} + {!loading && done && items.length === 0 && ( +No artworks to display
+ )} +