From 6fc641306a937af246642e8ee76a06abb785522c Mon Sep 17 00:00:00 2001 From: Citali Date: Sun, 21 Dec 2025 01:06:27 +0100 Subject: [PATCH] Enhance tags --- next.config.ts | 2 +- .../20251220224429_artwork_4/migration.sql | 5 + .../20251220231156_artwork_5/migration.sql | 22 +++ prisma/schema.prisma | 19 +++ src/actions/deleteItem.ts | 38 ++--- src/actions/tags/createTag.ts | 54 ++++--- src/actions/tags/deleteTag.ts | 40 ++++++ src/actions/tags/isDescendant.ts | 17 +++ src/actions/tags/updateTag.ts | 75 +++++++--- src/app/tags/[id]/page.tsx | 6 +- src/app/tags/new/page.tsx | 3 +- src/app/tags/page.tsx | 20 ++- src/components/tags/AliasEditor.tsx | 71 +++++++++ src/components/tags/EditTagForm.tsx | 74 +++++++--- src/components/tags/NewTagForm.tsx | 74 +++++++--- src/components/tags/TagTable.tsx | 136 ++++++++++++++++++ src/components/ui/table.tsx | 116 +++++++++++++++ src/components/uploads/UploadImageForm.tsx | 2 +- src/lib/queryArtworks.ts | 9 +- src/schemas/artworks/tagSchema.ts | 15 +- 20 files changed, 687 insertions(+), 111 deletions(-) create mode 100644 prisma/migrations/20251220224429_artwork_4/migration.sql create mode 100644 prisma/migrations/20251220231156_artwork_5/migration.sql create mode 100644 src/actions/tags/deleteTag.ts create mode 100644 src/actions/tags/isDescendant.ts create mode 100644 src/components/tags/AliasEditor.tsx create mode 100644 src/components/tags/TagTable.tsx create mode 100644 src/components/ui/table.tsx diff --git a/next.config.ts b/next.config.ts index 3c11f0b..f3af0f2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,7 +7,7 @@ const nextConfig: NextConfig = { module.exports = { experimental: { serverActions: { - bodySizeLimit: '5mb', + bodySizeLimit: '50mb', }, }, } diff --git a/prisma/migrations/20251220224429_artwork_4/migration.sql b/prisma/migrations/20251220224429_artwork_4/migration.sql new file mode 100644 index 0000000..adaaa5e --- /dev/null +++ b/prisma/migrations/20251220224429_artwork_4/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "ArtTag" ADD COLUMN "parentId" TEXT; + +-- AddForeignKey +ALTER TABLE "ArtTag" ADD CONSTRAINT "ArtTag_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ArtTag"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20251220231156_artwork_5/migration.sql b/prisma/migrations/20251220231156_artwork_5/migration.sql new file mode 100644 index 0000000..7e49fcb --- /dev/null +++ b/prisma/migrations/20251220231156_artwork_5/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "ArtTagAlias" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "alias" TEXT NOT NULL, + "tagId" TEXT NOT NULL, + + CONSTRAINT "ArtTagAlias_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ArtTagAlias_alias_key" ON "ArtTagAlias"("alias"); + +-- CreateIndex +CREATE INDEX "ArtTagAlias_alias_idx" ON "ArtTagAlias"("alias"); + +-- CreateIndex +CREATE UNIQUE INDEX "ArtTagAlias_tagId_alias_key" ON "ArtTagAlias"("tagId", "alias"); + +-- AddForeignKey +ALTER TABLE "ArtTagAlias" ADD CONSTRAINT "ArtTagAlias_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "ArtTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index acdebbe..e60b4df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -106,8 +106,27 @@ model ArtTag { description String? + aliases ArtTagAlias[] artworks Artwork[] categories ArtCategory[] + + parentId String? + parent ArtTag? @relation("TagHierarchy", fields: [parentId], references: [id], onDelete: SetNull) + children ArtTag[] @relation("TagHierarchy") +} + +model ArtTagAlias { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alias String @unique + + tagId String + tag ArtTag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([tagId, alias]) + @@index([alias]) } model Color { diff --git a/src/actions/deleteItem.ts b/src/actions/deleteItem.ts index a55f239..0d9b33c 100644 --- a/src/actions/deleteItem.ts +++ b/src/actions/deleteItem.ts @@ -1,23 +1,23 @@ -"use server"; +// "use server"; -import { prisma } from "@/lib/prisma"; +// import { prisma } from "@/lib/prisma"; -export async function deleteItems(itemId: string, type: string) { +// export async function deleteItems(itemId: string, type: string) { - switch (type) { - case "categories": - await prisma.artCategory.delete({ where: { id: itemId } }); - break; - case "tags": - await prisma.artTag.delete({ where: { id: itemId } }); - break; - // case "types": - // await prisma.portfolioType.delete({ where: { id: itemId } }); - // break; - // case "albums": - // await prisma.portfolioAlbum.delete({ where: { id: itemId } }); - // break; - } +// switch (type) { +// case "categories": +// await prisma.artCategory.delete({ where: { id: itemId } }); +// break; +// case "tags": +// await prisma.artTag.delete({ where: { id: itemId } }); +// break; +// // case "types": +// // await prisma.portfolioType.delete({ where: { id: itemId } }); +// // break; +// // case "albums": +// // await prisma.portfolioAlbum.delete({ where: { id: itemId } }); +// // break; +// } - return { success: true }; -} \ No newline at end of file +// return { success: true }; +// } \ No newline at end of file diff --git a/src/actions/tags/createTag.ts b/src/actions/tags/createTag.ts index cae71a8..b3b2c40 100644 --- a/src/actions/tags/createTag.ts +++ b/src/actions/tags/createTag.ts @@ -1,9 +1,9 @@ "use server" import { prisma } from "@/lib/prisma" -import { tagSchema } from "@/schemas/artworks/tagSchema" +import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema" -export async function createTag(formData: tagSchema) { +export async function createTag(formData: TagFormInput) { const parsed = tagSchema.safeParse(formData) if (!parsed.success) { @@ -12,25 +12,43 @@ export async function createTag(formData: tagSchema) { } const data = parsed.data + + const parentId = data.parentId ?? null; + const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); - const created = await prisma.artTag.create({ - data: { - name: data.name, - slug: data.slug, - description: data.description - }, - }) - - if (data.categoryIds) { - await prisma.artTag.update({ - where: { id: created.id }, + const created = await prisma.$transaction(async (tx) => { + const tag = await tx.artTag.create({ data: { - categories: { - set: data.categoryIds.map(id => ({ id })) - } - } + name: data.name, + slug: tagSlug, + description: data.description, + parentId + }, }); - } + + if (data.categoryIds) { + await tx.artTag.update({ + where: { id: tag.id }, + data: { + categories: { + set: data.categoryIds.map(id => ({ id })) + } + } + }); + } + + if (data.aliases && data.aliases.length > 0) { + await tx.artTagAlias.createMany({ + data: data.aliases.map((alias) => ({ + tagId: tag.id, + alias, + })), + skipDuplicates: true, + }); + } + + return tag; + }); return created } \ No newline at end of file diff --git a/src/actions/tags/deleteTag.ts b/src/actions/tags/deleteTag.ts new file mode 100644 index 0000000..eef3c8e --- /dev/null +++ b/src/actions/tags/deleteTag.ts @@ -0,0 +1,40 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export async function deleteTag(tagId: string) { + const tag = await prisma.artTag.findUnique({ + where: { id: tagId }, + select: { + id: true, + _count: { + select: { + artworks: true, + children: true, + }, + }, + }, + }); + + if (!tag) { + throw new Error("Tag not found."); + } + + if (tag._count.artworks > 0) { + throw new Error("Cannot delete tag: it is used by artworks."); + } + + if (tag._count.children > 0) { + throw new Error("Cannot delete tag: it has child tags."); + } + + await prisma.$transaction(async (tx) => { + await tx.artTagAlias.deleteMany({ where: { tagId } }); + await tx.artTag.delete({ where: { id: tagId } }); + }); + + revalidatePath("/tags"); + + return { success: true }; +} \ No newline at end of file diff --git a/src/actions/tags/isDescendant.ts b/src/actions/tags/isDescendant.ts new file mode 100644 index 0000000..8c0ff43 --- /dev/null +++ b/src/actions/tags/isDescendant.ts @@ -0,0 +1,17 @@ +"use server" + +import { prisma } from "@/lib/prisma"; + +export async function isDescendant(tagId: string, possibleAncestorId: string) { + // Walk upwards from possibleAncestorId; if we hit tagId, it's a cycle. + let current: string | null = possibleAncestorId; + while (current) { + if (current === tagId) return true; + const t = await prisma.artTag.findUnique({ + where: { id: current }, + select: { parentId: true }, + }); + current = t?.parentId ?? null; + } + return false; +} \ No newline at end of file diff --git a/src/actions/tags/updateTag.ts b/src/actions/tags/updateTag.ts index 80e5409..f415b18 100644 --- a/src/actions/tags/updateTag.ts +++ b/src/actions/tags/updateTag.ts @@ -1,10 +1,10 @@ "use server" import { prisma } from '@/lib/prisma'; -import { tagSchema } from '@/schemas/artworks/tagSchema'; -import { z } from 'zod/v4'; +import { TagFormInput, tagSchema } from '@/schemas/artworks/tagSchema'; +import { isDescendant } from './isDescendant'; -export async function updateTag(id: string, rawData: z.infer) { +export async function updateTag(id: string, rawData: TagFormInput) { const parsed = tagSchema.safeParse(rawData) if (!parsed.success) { @@ -14,25 +14,60 @@ export async function updateTag(id: string, rawData: z.infer) const data = parsed.data - const updated = await prisma.artTag.update({ - where: { id }, - data: { - name: data.name, - slug: data.slug, - description: data.description - }, - }) + const parentId = data.parentId ?? null; + const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); - if (data.categoryIds) { - await prisma.artTag.update({ - where: { id: id }, - data: { - categories: { - set: data.categoryIds.map(id => ({ id })) - } - } - }); + if (parentId === id) { + throw new Error("A tag cannot be its own parent."); } + if (parentId) { + const cycle = await isDescendant(id, parentId); + if (cycle) throw new Error("Invalid parent tag (would create a cycle)."); + } + + const updated = await prisma.$transaction(async (tx) => { + const tag = await tx.artTag.update({ + where: { id }, + data: { + name: data.name, + slug: tagSlug, + description: data.description, + parentId, + categories: data.categoryIds + ? { set: data.categoryIds.map((cid) => ({ id: cid })) } + : undefined, + }, + }); + + const existing = await tx.artTagAlias.findMany({ + where: { tagId: id }, + select: { id: true, alias: true }, + }); + + const desired = new Set((data.aliases ?? []).map((a) => a)); + const existingSet = new Set(existing.map((a) => a.alias)); + + const toCreate = Array.from(desired).filter((a) => !existingSet.has(a)); + const toDeleteIds = existing + .filter((a) => !desired.has(a.alias)) + .map((a) => a.id); + + if (toDeleteIds.length > 0) { + await tx.artTagAlias.deleteMany({ + where: { id: { in: toDeleteIds } }, + }); + } + + if (toCreate.length > 0) { + await tx.artTagAlias.createMany({ + data: toCreate.map((alias) => ({ tagId: id, alias })), + skipDuplicates: true, + }); + } + + return tag; + }); + return updated } \ No newline at end of file diff --git a/src/app/tags/[id]/page.tsx b/src/app/tags/[id]/page.tsx index 34dbd02..6f81840 100644 --- a/src/app/tags/[id]/page.tsx +++ b/src/app/tags/[id]/page.tsx @@ -8,16 +8,18 @@ export default async function PortfolioTagsEditPage({ params }: { params: { id: id, }, include: { - categories: true + categories: true, + aliases: true } }) const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }); + const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } }); return (

Edit Tag

- {tag && } + {tag && }
); } \ No newline at end of file diff --git a/src/app/tags/new/page.tsx b/src/app/tags/new/page.tsx index 2ed755a..87c23d2 100644 --- a/src/app/tags/new/page.tsx +++ b/src/app/tags/new/page.tsx @@ -3,11 +3,12 @@ import { prisma } from "@/lib/prisma"; export default async function PortfolioTagsNewPage() { const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }); + const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } }); return (

New Tag

- +
); } \ No newline at end of file diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx index 82755e7..4539939 100644 --- a/src/app/tags/page.tsx +++ b/src/app/tags/page.tsx @@ -1,10 +1,18 @@ -import ItemList from "@/components/lists/ItemList"; +import TagTable from "@/components/tags/TagTable"; import { prisma } from "@/lib/prisma"; import { PlusCircleIcon } from "lucide-react"; import Link from "next/link"; -export default async function PortfolioTagsPage() { - const items = await prisma.artTag.findMany({}) +export default async function ArtTagsPage() { + const items = await prisma.artTag.findMany({ + include: { + parent: { select: { id: true, name: true } }, + aliases: { select: { alias: true } }, + categories: { select: { id: true, name: true } }, + _count: { select: { artworks: true } }, + }, + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }); return (
@@ -14,7 +22,11 @@ export default async function PortfolioTagsPage() { Add new tag
- {items && items.length > 0 ? :

There are no tags yet. Consider adding some!

} + {items.length > 0 ? ( + + ) : ( +

There are no tags yet. Consider adding some!

+ )} ); } \ No newline at end of file diff --git a/src/components/tags/AliasEditor.tsx b/src/components/tags/AliasEditor.tsx new file mode 100644 index 0000000..359d0c2 --- /dev/null +++ b/src/components/tags/AliasEditor.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export default function AliasEditor({ + value, + onChange, +}: { + value: string[]; + onChange: (next: string[]) => void; +}) { + const [draft, setDraft] = useState(""); + + const add = () => { + const v = draft.trim(); + if (!v) return; + if (value.includes(v)) { + setDraft(""); + return; + } + onChange([...value, v]); + setDraft(""); + }; + + const remove = (alias: string) => { + onChange(value.filter((a) => a !== alias)); + }; + + return ( +
+
+ setDraft(e.target.value)} + placeholder='Add alias, e.g. "anthro"' + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + /> + +
+ +
+ {value.length === 0 ? ( + No aliases. + ) : ( + value.map((a) => ( + + {a} + + + )) + )} +
+
+ ); +} diff --git a/src/components/tags/EditTagForm.tsx b/src/components/tags/EditTagForm.tsx index f325f96..281e58e 100644 --- a/src/components/tags/EditTagForm.tsx +++ b/src/components/tags/EditTagForm.tsx @@ -5,28 +5,30 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { ArtCategory, ArtTag } from "@/generated/prisma/client"; -import { tagSchema } from "@/schemas/artworks/tagSchema"; +import { ArtCategory, ArtTag, ArtTagAlias } from "@/generated/prisma/client"; +import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod/v4"; import MultipleSelector from "../ui/multiselect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import AliasEditor from "./AliasEditor"; -export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categories: ArtCategory[] }, categories: ArtCategory[] }) { +export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag & { categories: ArtCategory[], aliases: ArtTagAlias[] }, categories: ArtCategory[], allTags: ArtTag[] }) { const router = useRouter(); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(tagSchema), defaultValues: { name: tag.name, - slug: tag.slug, description: tag.description || "", categoryIds: tag.categories?.map(cat => cat.id) ?? [], + parentId: (tag as any).parentId ?? null, + aliases: tag.aliases?.map(a => a.alias) ?? [] } }) - async function onSubmit(values: z.infer) { + async function onSubmit(values: TagFormInput) { try { const updated = await updateTag(tag.id, values) console.log("Art tag updated:", updated) @@ -38,6 +40,10 @@ export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categ } } + const parentOptions = allTags + .filter((t) => t.id !== tag.id) // exclude self + .sort((a, b) => a.name.localeCompare(b.name)); + return (
@@ -56,19 +62,6 @@ export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categ )} /> - ( - - Slug - - - - - - )} - /> + ( + + Parent tag + + + + )} + /> + ( + + Aliases + + + + + + )} + />
diff --git a/src/components/tags/NewTagForm.tsx b/src/components/tags/NewTagForm.tsx index 63f151f..12cacd2 100644 --- a/src/components/tags/NewTagForm.tsx +++ b/src/components/tags/NewTagForm.tsx @@ -5,29 +5,31 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { ArtCategory } from "@/generated/prisma/client"; -import { tagSchema } from "@/schemas/artworks/tagSchema"; +import { ArtCategory, ArtTag } from "@/generated/prisma/client"; +import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod/v4"; import MultipleSelector from "../ui/multiselect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import AliasEditor from "./AliasEditor"; -export default function NewTagForm({ categories }: { categories: ArtCategory[] }) { +export default function NewTagForm({ categories, allTags }: { categories: ArtCategory[], allTags: ArtTag[] }) { const router = useRouter(); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(tagSchema), defaultValues: { name: "", - slug: "", description: "", categoryIds: [], + parentId: null, + aliases: [], } }) - async function onSubmit(values: z.infer) { + async function onSubmit(values: TagFormInput) { try { const created = await createTag(values) console.log("Art tag created:", created) @@ -39,6 +41,10 @@ export default function NewTagForm({ categories }: { categories: ArtCategory[] } } } + const parentOptions = allTags + // .filter((t) => t.id !== tag.id) // exclude self + .sort((a, b) => a.name.localeCompare(b.name)); + return (
@@ -57,19 +63,6 @@ export default function NewTagForm({ categories }: { categories: ArtCategory[] } )} /> - ( - - Slug - - - - - - )} - /> + ( + + Parent tag + + + + )} + /> + ( + + Aliases + + + + + + )} + />
diff --git a/src/components/tags/TagTable.tsx b/src/components/tags/TagTable.tsx new file mode 100644 index 0000000..ccaa714 --- /dev/null +++ b/src/components/tags/TagTable.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { deleteTag } from "@/actions/tags/deleteTag"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PencilIcon, Trash2Icon } from "lucide-react"; +import Link from "next/link"; + +type TagRow = { + id: string; + name: string; + slug: string; + parent: { id: string; name: string } | null; + aliases: { alias: string }[]; + categories: { id: string; name: string }[]; + _count: { artworks: number }; +}; + +function Chips({ + values, + empty = "—", + max = 4, + mono = false, +}: { + values: string[]; + empty?: string; + max?: number; + mono?: boolean; +}) { + if (values.length === 0) return {empty}; + + const shown = values.slice(0, max); + const extra = values.length - shown.length; + + return ( +
+ {shown.map((v) => ( + + {v} + + ))} + {extra > 0 ? +{extra} more : null} +
+ ); +} + +export default function TagTable({ tags }: { tags: TagRow[] }) { + const handleDelete = (id: string) => { + deleteTag(id); + }; + + return ( +
+ + + + Name + Slug + Aliases + Categories + Parent + Artworks + + + + + + {tags.map((t) => ( + + {t.name} + + + #{t.slug} + + + + a.alias)} mono max={5} /> + + + + c.name)} max={4} /> + + + + {t.parent ? ( + + {t.parent.name} + + ) : ( + + )} + + + + {t._count.artworks} + + + +
+ + + + + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000..51b74dd --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/components/uploads/UploadImageForm.tsx b/src/components/uploads/UploadImageForm.tsx index 38e803c..5315cf1 100644 --- a/src/components/uploads/UploadImageForm.tsx +++ b/src/components/uploads/UploadImageForm.tsx @@ -44,7 +44,7 @@ export default function UploadImageForm() { const image = await createImage(values) if (image) { toast.success("Image created") - router.push(`/portfolio/images/${image.id}`) + router.push(`/artworks/${image.id}`) } } diff --git a/src/lib/queryArtworks.ts b/src/lib/queryArtworks.ts index 372701a..3947712 100644 --- a/src/lib/queryArtworks.ts +++ b/src/lib/queryArtworks.ts @@ -9,7 +9,7 @@ export type ArtworkListParams = { }; export async function getArtworksPage(params: ArtworkListParams) { - const { published = "all", take = 48, cursor } = params; + const { published = "all", cursor, take = 48 } = params; const where: Prisma.ArtworkWhereInput = {}; @@ -21,8 +21,13 @@ export async function getArtworksPage(params: ArtworkListParams) { where, include: { file: true, - variants: true, + gallery: true, metadata: true, + albums: true, + categories: true, + colors: true, + tags: true, + variants: true, }, orderBy: [{ createdAt: "desc" }, { id: "asc" }], take: take + 1, // fetch one extra to know if there is a next page diff --git a/src/schemas/artworks/tagSchema.ts b/src/schemas/artworks/tagSchema.ts index 2064639..28e7f82 100644 --- a/src/schemas/artworks/tagSchema.ts +++ b/src/schemas/artworks/tagSchema.ts @@ -1,11 +1,20 @@ -import { z } from "zod/v4" +import { z } from "zod/v4"; export const tagSchema = z.object({ name: z.string().min(3, "Name is required. Min 3 characters."), - slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"), description: z.string().optional(), categoryIds: z.array(z.string()).optional(), + parentId: z.string().nullable().optional(), + + aliases: z + .array(z.string().trim().min(1)) + .default([]) + .transform((arr) => { + const normalized = arr.map((s) => s.trim().toLowerCase()).filter(Boolean); + return Array.from(new Set(normalized)); + }), }) -export type tagSchema = z.infer +export type TagFormInput = z.input; +export type TagFormOutput = z.output;