diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb52aa8..b2036e5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,13 +47,13 @@ model Artwork { galleryId String? gallery Gallery? @relation(fields: [galleryId], references: [id]) - metadata ArtworkMetadata? + metadata ArtworkMetadata? timelapse ArtworkTimelapse? albums Album[] categories ArtCategory[] colors ArtworkColor[] - tags Tag[] @relation("ArtworkTags") + tags Tag[] @relation("ArtworkTags") variants FileVariant[] @@index([colorStatus]) @@ -165,12 +165,12 @@ model ArtworkTimelapse { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - artworkId String @unique + artworkId String @unique artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade) enabled Boolean @default(false) - s3Key String @unique + s3Key String @unique fileName String? mimeType String? sizeBytes Int? @@ -224,12 +224,13 @@ model Tag { description String? - aliases TagAlias[] - categoryLinks TagCategory[] - categoryParents TagCategory[] @relation("TagCategoryParent") - artworks Artwork[] @relation("ArtworkTags") - commissionTypes CommissionType[] @relation("CommissionTypeTags") - miniatures Miniature[] @relation("MiniatureTags") + aliases TagAlias[] + categoryLinks TagCategory[] + categoryParents TagCategory[] @relation("TagCategoryParent") + artworks Artwork[] @relation("ArtworkTags") + commissionTypes CommissionType[] @relation("CommissionTypeTags") + commissionCustomCards CommissionCustomCard[] @relation("CommissionCustomCardTags") + miniatures Miniature[] @relation("MiniatureTags") } model TagAlias { @@ -240,7 +241,7 @@ model TagAlias { alias String @unique tagId String - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) @@unique([tagId, alias]) @@index([alias]) @@ -310,13 +311,14 @@ model CommissionCustomCard { name String - description String? + description String? referenceImageUrl String? - isVisible Boolean @default(true) - isSpecialOffer Boolean @default(false) + isVisible Boolean @default(true) + isSpecialOffer Boolean @default(false) - options CommissionCustomCardOption[] - extras CommissionCustomCardExtra[] + tags Tag[] @relation("CommissionCustomCardTags") + options CommissionCustomCardOption[] + extras CommissionCustomCardExtra[] requests CommissionRequest[] @@index([isVisible, sortIndex]) @@ -332,9 +334,9 @@ model CommissionOption { description String? - types CommissionTypeOption[] + types CommissionTypeOption[] customCards CommissionCustomCardOption[] - requests CommissionRequest[] + requests CommissionRequest[] } model CommissionTypeOption { @@ -366,8 +368,8 @@ model CommissionExtra { description String? - requests CommissionRequest[] - types CommissionTypeExtra[] + requests CommissionRequest[] + types CommissionTypeExtra[] customCards CommissionCustomCardExtra[] } @@ -475,12 +477,12 @@ model CommissionRequest { userAgent String? customFields Json? - optionId String? - typeId String? + optionId String? + typeId String? customCardId String? - option CommissionOption? @relation(fields: [optionId], references: [id]) - type CommissionType? @relation(fields: [typeId], references: [id]) - customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id]) + option CommissionOption? @relation(fields: [optionId], references: [id]) + type CommissionType? @relation(fields: [typeId], references: [id]) + customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id]) extras CommissionExtra[] files CommissionRequestFile[] @@ -491,9 +493,9 @@ model CommissionGuidelines { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - markdown String + markdown String exampleImageUrl String? - isActive Boolean @default(true) + isActive Boolean @default(true) @@index([isActive]) } diff --git a/src/actions/portfolio/getTaggedArtworksPage.ts b/src/actions/portfolio/getTaggedArtworksPage.ts new file mode 100644 index 0000000..bd6b13c --- /dev/null +++ b/src/actions/portfolio/getTaggedArtworksPage.ts @@ -0,0 +1,173 @@ +"use server"; + +import type { Prisma } from "@/generated/prisma/browser"; +import { prisma } from "@/lib/prisma"; + +export type Cursor = { + afterSortKey: number | null; + afterId: string; +} | null; + +export type TaggedArtworkItem = { + id: string; + name: string; + altText: string | null; + sortKey: number | null; + year: number | null; + fileKey: string; + thumbW: number; + thumbH: number; + dominantHex: string; +}; + +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 getTaggedArtworksPage(args: { + take?: number; + cursor?: Cursor; + tagSlugs: string[]; + onlyPublished?: boolean; +}): Promise<{ + items: TaggedArtworkItem[]; + nextCursor: Cursor; + total: number; +}> { + const { take = 60, cursor = null, tagSlugs, onlyPublished = true } = args; + + const filteredSlugs = tagSlugs.map((s) => s.trim()).filter(Boolean); + if (filteredSlugs.length === 0) { + return { items: [], nextCursor: null, total: 0 }; + } + + const baseWhere: Prisma.ArtworkWhereInput = { + ...(onlyPublished ? { published: true } : {}), + tags: { some: { slug: { in: filteredSlugs } } }, + variants: { some: { type: "thumbnail" } }, + }; + + const total = await prisma.artwork.count({ where: baseWhere }); + + const select = { + id: true, + name: true, + altText: true, + year: true, + sortKey: true, + file: { select: { fileKey: true } }, + variants: { + where: { type: "thumbnail" }, + select: { type: true, width: true, height: true }, + take: 1, + }, + colors: { + where: { type: "Vibrant" }, + select: { color: { select: { hex: true } } }, + take: 1, + }, + } satisfies Prisma.ArtworkSelect; + + type ArtworkRow = Prisma.ArtworkGetPayload<{ select: typeof select }>; + + const mapRow = (r: ArtworkRow): TaggedArtworkItem | null => { + const thumb = pickVariant(r.variants, "thumbnail"); + if (!thumb?.width || !thumb?.height) return null; + + return { + id: r.id, + name: r.name, + 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", + }; + }; + + let items: TaggedArtworkItem[] = []; + let nextCursor: Cursor = null; + + const inNullSegment = cursor?.afterSortKey === null; + + if (!inNullSegment) { + const whereA: Prisma.ArtworkWhereInput = { + AND: [baseWhere, { sortKey: { not: null } }], + }; + + 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, + }); + + items = rowsA.map(mapRow).filter((x): x is TaggedArtworkItem => x !== null); + + if (items.length >= take) { + const last = items.at(-1); + if (!last || last.sortKey == null) { + return { items, nextCursor: null, total }; + } + nextCursor = { afterSortKey: last.sortKey, afterId: last.id }; + return { items, nextCursor, total }; + } + + const remaining = take - items.length; + + const whereB: Prisma.ArtworkWhereInput = { + AND: [baseWhere, { sortKey: null }], + }; + + const rowsB = await prisma.artwork.findMany({ + where: whereB, + orderBy: [{ id: "asc" }], + take: Math.min(remaining, 200), + select, + }); + + const more = rowsB.map(mapRow).filter((x): x is TaggedArtworkItem => 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, total }; + } + + const whereB: Prisma.ArtworkWhereInput = { + AND: [baseWhere, { sortKey: null }], + ...(cursor ? { id: { gt: cursor.afterId } } : {}), + }; + + const rowsB = await prisma.artwork.findMany({ + where: whereB, + orderBy: [{ id: "asc" }], + take: Math.min(take, 200), + select, + }); + + items = rowsB.map(mapRow).filter((x): x is TaggedArtworkItem => x !== null); + + const last = items[items.length - 1]; + nextCursor = + items.length < take || !last + ? null + : { afterSortKey: null, afterId: last.id }; + + return { items, nextCursor, total }; +} diff --git a/src/app/(normal)/artworks/tagged/page.tsx b/src/app/(normal)/artworks/tagged/page.tsx new file mode 100644 index 0000000..4a15811 --- /dev/null +++ b/src/app/(normal)/artworks/tagged/page.tsx @@ -0,0 +1,48 @@ +import TaggedGallery from "@/components/portfolio/TaggedGallery"; +import { prisma } from "@/lib/prisma"; + +function parseTagsParam(tags: string | string[] | undefined): string[] { + if (!tags) return []; + const raw = Array.isArray(tags) ? tags.join(",") : tags; + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); +} + +export default async function TaggedPortfolioPage({ + searchParams, +}: { + searchParams: { tags?: string | string[] }; +}) { + const { tags } = await searchParams; + const selectedTagSlugs = parseTagsParam(tags); + + const tagsSelected = selectedTagSlugs.length + ? await prisma.tag.findMany({ + where: { slug: { in: selectedTagSlugs } }, + select: { id: true, name: true, slug: true }, + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }) + : []; + + return ( +