From c4107718d07d3d5570d7890aec6bc66681a1cbcc Mon Sep 17 00:00:00 2001 From: Citali Date: Mon, 2 Feb 2026 16:59:27 +0100 Subject: [PATCH] Add tags to commssion types and custom types. Add button for example images to cards --- prisma/schema.prisma | 56 +++--- .../portfolio/getTaggedArtworksPage.ts | 173 ++++++++++++++++++ src/app/(normal)/artworks/tagged/page.tsx | 48 +++++ src/app/(normal)/commissions/page.tsx | 14 +- src/components/commissions/CommissionCard.tsx | 90 +++++---- .../commissions/CommissionCustomCard.tsx | 19 +- src/components/portfolio/TaggedGallery.tsx | 116 ++++++++++++ 7 files changed, 439 insertions(+), 77 deletions(-) create mode 100644 src/actions/portfolio/getTaggedArtworksPage.ts create mode 100644 src/app/(normal)/artworks/tagged/page.tsx create mode 100644 src/components/portfolio/TaggedGallery.tsx 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 ( +
+
+
+

+ {tagsSelected.length ? ( +
+ List of artworks tagged with: + {tagsSelected.map((t) => ( + {t.name.toLowerCase()} + ))} +
+ ) : "No tags selected"} +

+
+
+ +
+ ); +} diff --git a/src/app/(normal)/commissions/page.tsx b/src/app/(normal)/commissions/page.tsx index d73da3e..6287be7 100644 --- a/src/app/(normal)/commissions/page.tsx +++ b/src/app/(normal)/commissions/page.tsx @@ -19,7 +19,11 @@ export default async function CommissionsPage() { include: { options: { include: { option: true }, orderBy: { sortIndex: "asc" } }, extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } }, - customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } }, + customInputs: { + include: { customInput: true }, + orderBy: { sortIndex: "asc" }, + }, + tags: true, }, orderBy: [{ sortIndex: "asc" }, { name: "asc" }], }), @@ -28,6 +32,7 @@ export default async function CommissionsPage() { include: { options: { include: { option: true }, orderBy: { sortIndex: "asc" } }, extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } }, + tags: true, }, orderBy: [{ sortIndex: "asc" }, { name: "asc" }], }), @@ -49,7 +54,7 @@ export default async function CommissionsPage() { {guidelines?.exampleImageUrl ? ( - + @@ -80,7 +85,10 @@ export default async function CommissionsPage() {
-

+

Request a Commission

diff --git a/src/components/commissions/CommissionCard.tsx b/src/components/commissions/CommissionCard.tsx index b6d4858..7233186 100644 --- a/src/components/commissions/CommissionCard.tsx +++ b/src/components/commissions/CommissionCard.tsx @@ -1,52 +1,43 @@ -"use client" +"use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import type { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client" +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { + CommissionExtra, + CommissionOption, + CommissionType, + CommissionTypeExtra, + CommissionTypeOption, + Tag, +} from "@/generated/prisma/client"; +import Link from "next/link"; type CommissionTypeWithItems = CommissionType & { options: (CommissionTypeOption & { - option: CommissionOption | null - })[] + option: CommissionOption | null; + })[]; extras: (CommissionTypeExtra & { - extra: CommissionExtra | null - })[] -} - -export function CommissionCard({ commission }: { commission: CommissionTypeWithItems }) { - // const [open, setOpen] = useState(false) + extra: CommissionExtra | null; + })[]; + tags: Tag[]; +}; +export function CommissionCard({ + commission, +}: { + commission: CommissionTypeWithItems; +}) { return (
{commission.name} -

{commission.description}

+

+ {commission.description} +

- - {/* {examples && examples.length > 0 && ( - - - {open ? "Hide Examples" : "See Examples"} - - -
-
- {examples.map((src, idx) => ( - {`${type.name} - ))} -
-
-
-
- )} */} +

Options

    @@ -66,7 +57,9 @@ export function CommissionCard({ commission }: { commission: CommissionTypeWithI
- {commission.extras.length > 0 &&

Extras

} + {commission.extras.length > 0 && ( +

Extras

+ )}
    {commission.extras.map((extra) => (
  • @@ -82,16 +75,21 @@ export function CommissionCard({ commission }: { commission: CommissionTypeWithI ))}
- - {/*
- {commission.extras.map((extra) => ( - - {extra.extra?.name} - - ))} -
*/}
+ {commission.tags.length > 0 ? ( +
+ t.slug).join(","), + )}`} + > + + +
+ ) : null}
- ) + ); } diff --git a/src/components/commissions/CommissionCustomCard.tsx b/src/components/commissions/CommissionCustomCard.tsx index 867a257..2f1901b 100644 --- a/src/components/commissions/CommissionCustomCard.tsx +++ b/src/components/commissions/CommissionCustomCard.tsx @@ -1,5 +1,6 @@ "use client"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, @@ -8,8 +9,10 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import type { Tag } from "@/generated/prisma/client"; import { cn } from "@/lib/utils"; import Image from "next/image"; +import Link from "next/link"; type CustomCardOption = { id: string; @@ -33,6 +36,7 @@ export type CommissionCustomCardWithItems = { description: string | null; referenceImageUrl: string | null; isSpecialOffer: boolean; + tags: Tag[]; options: CustomCardOption[]; extras: CustomCardExtra[]; }; @@ -50,7 +54,7 @@ export function CommissionCustomCard({ "flex flex-col h-full relative shadow-sm", card.isSpecialOffer ? "border-2 border-primary/50" - : "border-border" + : "border-border", )} > {card.isSpecialOffer ? ( @@ -143,6 +147,19 @@ export function CommissionCustomCard({ + {card.tags.length > 0 ? ( +
+ t.slug).join(","), + )}`} + > + + +
+ ) : null} diff --git a/src/components/portfolio/TaggedGallery.tsx b/src/components/portfolio/TaggedGallery.tsx new file mode 100644 index 0000000..deff121 --- /dev/null +++ b/src/components/portfolio/TaggedGallery.tsx @@ -0,0 +1,116 @@ +"use client"; + +import type { + Cursor, + TaggedArtworkItem, +} from "@/actions/portfolio/getTaggedArtworksPage"; +import { getTaggedArtworksPage } from "@/actions/portfolio/getTaggedArtworksPage"; +import JustifiedGallery, { + type JustifiedGalleryItem, +} from "@/components/gallery/JustifiedGallery"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export default function TaggedGallery({ tagSlugs }: { tagSlugs: string[] }) { + const normalizedSlugs = useMemo( + () => tagSlugs.map((s) => s.trim()).filter(Boolean), + [tagSlugs], + ); + const resetKey = useMemo( + () => normalizedSlugs.slice().sort().join(","), + [normalizedSlugs], + ); + + const [items, setItems] = useState([]); + const [done, setDone] = useState(false); + const [loading, setLoading] = useState(false); + + const inFlight = useRef(false); + const doneRef = useRef(false); + doneRef.current = done; + const cursorRef = useRef(null); + + useEffect(() => { + setItems([]); + setDone(false); + doneRef.current = false; + inFlight.current = false; + cursorRef.current = null; + }, [resetKey]); + + const loadMore = useCallback(async () => { + if (inFlight.current || doneRef.current || normalizedSlugs.length === 0) + return 0; + inFlight.current = true; + setLoading(true); + + try { + const data = await getTaggedArtworksPage({ + take: 60, + cursor: cursorRef.current, + tagSlugs: normalizedSlugs, + onlyPublished: true, + }); + + 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); + }); + + cursorRef.current = data.nextCursor; + if (!data.nextCursor) setDone(true); + + return data.items.length; + } finally { + setLoading(false); + inFlight.current = false; + } + }, [normalizedSlugs]); + + useEffect(() => { + void loadMore(); + }, [loadMore]); + + const galleryItems: JustifiedGalleryItem[] = items.map((it) => ({ + id: it.id, + name: it.name, + altText: it.altText, + fileKey: it.fileKey, + width: it.thumbW, + height: it.thumbH, + dominantHex: it.dominantHex, + })); + + if (!loading && done && galleryItems.length === 0) { + return ( +

+ No artworks to display +

+ ); + } + + return ( +
+ void loadMore()} + hasMore={!done} + isLoadingMore={loading} + /> +
+ ); +}