From 7605ccb0aa68e412f6a78065776ef4d9af92bb2a Mon Sep 17 00:00:00 2001 From: Citali Date: Mon, 2 Feb 2026 13:05:52 +0100 Subject: [PATCH] Moving the arttags table to tags table part 1 --- .../20260202114116_tags_01/migration.sql | 140 ++++++++++++++++++ prisma/schema.prisma | 69 +++++++++ src/actions/artworks/deleteArtwork.ts | 4 +- src/actions/artworks/getArtworks.ts | 4 +- src/actions/artworks/getArtworksTablePage.ts | 6 +- src/actions/artworks/updateArtwork.ts | 4 +- src/actions/categories/deleteCategory.ts | 6 +- src/actions/categories/getCategories.ts | 9 +- src/actions/tags/createTag.ts | 26 ++-- src/actions/tags/deleteTag.ts | 16 +- src/actions/tags/getTags.ts | 4 +- src/actions/tags/isDescendant.ts | 28 ++-- src/actions/tags/migrateArtTags.ts | 102 +++++++++++++ src/actions/tags/updateTag.ts | 62 ++++++-- src/app/(admin)/tags/[id]/page.tsx | 32 +++- src/app/(admin)/tags/new/page.tsx | 9 +- src/app/(admin)/tags/page.tsx | 57 +++++-- .../artworks/single/ArtworkDetails.tsx | 2 +- .../artworks/single/EditArtworkForm.tsx | 10 +- src/components/categories/CategoryTable.tsx | 4 +- src/components/global/TopNav.tsx | 22 +++ src/components/global/nav.ts | 7 +- src/components/tags/EditTagForm.tsx | 48 +++++- src/components/tags/NewTagForm.tsx | 30 +++- src/lib/queryArtworks.ts | 2 +- src/schemas/artworks/tagSchema.ts | 2 +- src/types/Artwork.ts | 6 +- 27 files changed, 604 insertions(+), 107 deletions(-) create mode 100644 prisma/migrations/20260202114116_tags_01/migration.sql create mode 100644 src/actions/tags/migrateArtTags.ts diff --git a/prisma/migrations/20260202114116_tags_01/migration.sql b/prisma/migrations/20260202114116_tags_01/migration.sql new file mode 100644 index 0000000..be62637 --- /dev/null +++ b/prisma/migrations/20260202114116_tags_01/migration.sql @@ -0,0 +1,140 @@ +-- CreateTable +CREATE TABLE "Tag" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "sortIndex" INTEGER NOT NULL DEFAULT 0, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "isVisible" BOOLEAN NOT NULL DEFAULT true, + "description" TEXT, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TagAlias" ( + "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 "TagAlias_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TagCategory" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "tagId" TEXT NOT NULL, + "categoryId" TEXT NOT NULL, + "isParent" BOOLEAN NOT NULL DEFAULT false, + "showOnAnimalPage" BOOLEAN NOT NULL DEFAULT false, + "parentTagId" TEXT, + + CONSTRAINT "TagCategory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Miniature" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Miniature_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ArtworkTagsV2" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ArtworkTagsV2_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_MiniatureTags" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_MiniatureTags_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_CommissionTypeTags" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_CommissionTypeTags_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_slug_key" ON "Tag"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "TagAlias_alias_key" ON "TagAlias"("alias"); + +-- CreateIndex +CREATE INDEX "TagAlias_alias_idx" ON "TagAlias"("alias"); + +-- CreateIndex +CREATE UNIQUE INDEX "TagAlias_tagId_alias_key" ON "TagAlias"("tagId", "alias"); + +-- CreateIndex +CREATE INDEX "TagCategory_categoryId_idx" ON "TagCategory"("categoryId"); + +-- CreateIndex +CREATE INDEX "TagCategory_tagId_idx" ON "TagCategory"("tagId"); + +-- CreateIndex +CREATE INDEX "TagCategory_parentTagId_idx" ON "TagCategory"("parentTagId"); + +-- CreateIndex +CREATE INDEX "TagCategory_categoryId_parentTagId_idx" ON "TagCategory"("categoryId", "parentTagId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TagCategory_tagId_categoryId_key" ON "TagCategory"("tagId", "categoryId"); + +-- CreateIndex +CREATE INDEX "_ArtworkTagsV2_B_index" ON "_ArtworkTagsV2"("B"); + +-- CreateIndex +CREATE INDEX "_MiniatureTags_B_index" ON "_MiniatureTags"("B"); + +-- CreateIndex +CREATE INDEX "_CommissionTypeTags_B_index" ON "_CommissionTypeTags"("B"); + +-- AddForeignKey +ALTER TABLE "TagAlias" ADD CONSTRAINT "TagAlias_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagCategory" ADD CONSTRAINT "TagCategory_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagCategory" ADD CONSTRAINT "TagCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ArtCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TagCategory" ADD CONSTRAINT "TagCategory_parentTagId_fkey" FOREIGN KEY ("parentTagId") REFERENCES "Tag"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArtworkTagsV2" ADD CONSTRAINT "_ArtworkTagsV2_A_fkey" FOREIGN KEY ("A") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArtworkTagsV2" ADD CONSTRAINT "_ArtworkTagsV2_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MiniatureTags" ADD CONSTRAINT "_MiniatureTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Miniature"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_MiniatureTags" ADD CONSTRAINT "_MiniatureTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CommissionTypeTags" ADD CONSTRAINT "_CommissionTypeTags_A_fkey" FOREIGN KEY ("A") REFERENCES "CommissionType"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CommissionTypeTags" ADD CONSTRAINT "_CommissionTypeTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e896983..cf97c26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,6 +54,7 @@ model Artwork { categories ArtCategory[] colors ArtworkColor[] tags ArtTag[] + tagsV2 Tag[] @relation("ArtworkTagsV2") variants FileVariant[] @@index([colorStatus]) @@ -102,6 +103,7 @@ model ArtCategory { artworks Artwork[] tags ArtTag[] + tagLinks TagCategory[] } model ArtTag { @@ -126,6 +128,63 @@ model ArtTag { children ArtTag[] @relation("TagHierarchy") } +model Tag { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + name String @unique + slug String @unique + isVisible Boolean @default(true) + + description String? + + aliases TagAlias[] + categoryLinks TagCategory[] + categoryParents TagCategory[] @relation("TagCategoryParent") + artworks Artwork[] @relation("ArtworkTagsV2") + commissionTypes CommissionType[] @relation("CommissionTypeTags") + miniatures Miniature[] @relation("MiniatureTags") +} + +model TagAlias { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + alias String @unique + + tagId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@unique([tagId, alias]) + @@index([alias]) +} + +model TagCategory { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tagId String + categoryId String + + isParent Boolean @default(false) + showOnAnimalPage Boolean @default(false) + parentTagId String? + + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + category ArtCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) + parentTag Tag? @relation("TagCategoryParent", fields: [parentTagId], references: [id], onDelete: SetNull) + + @@unique([tagId, categoryId]) + @@index([categoryId]) + @@index([tagId]) + @@index([parentTagId]) + @@index([categoryId, parentTagId]) +} + model ArtTagAlias { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -140,6 +199,14 @@ model ArtTagAlias { @@index([alias]) } +model Miniature { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tags Tag[] @relation("MiniatureTags") +} + model Color { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -265,6 +332,8 @@ model CommissionType { description String? + tags Tag[] @relation("CommissionTypeTags") + options CommissionTypeOption[] extras CommissionTypeExtra[] customInputs CommissionTypeCustomInput[] diff --git a/src/actions/artworks/deleteArtwork.ts b/src/actions/artworks/deleteArtwork.ts index 7858b6b..a6e4df3 100644 --- a/src/actions/artworks/deleteArtwork.ts +++ b/src/actions/artworks/deleteArtwork.ts @@ -13,6 +13,7 @@ export async function deleteArtwork(artworkId: string) { colors: true, metadata: true, tags: true, + tagsV2: true, categories: true, }, }); @@ -74,6 +75,7 @@ export async function deleteArtwork(artworkId: string) { where: { id: artworkId }, data: { tags: { set: [] }, + tagsV2: { set: [] }, categories: { set: [] }, }, }); @@ -82,4 +84,4 @@ export async function deleteArtwork(artworkId: string) { await prisma.artwork.delete({ where: { id: artworkId } }); return { success: true }; -} \ No newline at end of file +} diff --git a/src/actions/artworks/getArtworks.ts b/src/actions/artworks/getArtworks.ts index 48eff3e..50b6391 100644 --- a/src/actions/artworks/getArtworks.ts +++ b/src/actions/artworks/getArtworks.ts @@ -15,9 +15,9 @@ export async function getSingleArtwork(id: string) { categories: true, colors: { include: { color: true } }, // sortContexts: true, - tags: true, + tagsV2: true, variants: true, timelapse: true } }) -} \ No newline at end of file +} diff --git a/src/actions/artworks/getArtworksTablePage.ts b/src/actions/artworks/getArtworksTablePage.ts index c7d3099..8e5de81 100644 --- a/src/actions/artworks/getArtworksTablePage.ts +++ b/src/actions/artworks/getArtworksTablePage.ts @@ -22,7 +22,7 @@ function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) { // relation counts: Prisma supports ordering by _count albumsCount: (desc) => ({ albums: { _count: desc ? "desc" : "asc" } }), categoriesCount: (desc) => ({ categories: { _count: desc ? "desc" : "asc" } }), - tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }), + tagsCount: (desc) => ({ tagsV2: { _count: desc ? "desc" : "asc" } }), }; const orderBy = sorting @@ -89,7 +89,7 @@ export async function getArtworksTablePage(input: unknown) { gallery: { select: { id: true, name: true } }, albums: { select: { id: true, name: true } }, categories: { select: { id: true, name: true } }, - _count: { select: { albums: true, categories: true, tags: true } }, + _count: { select: { albums: true, categories: true, tagsV2: true } }, }, }), ]); @@ -109,7 +109,7 @@ export async function getArtworksTablePage(input: unknown) { categories: a.categories, albumsCount: a._count.albums, categoriesCount: a._count.categories, - tagsCount: a._count.tags, + tagsCount: a._count.tagsV2, })); const out = { rows, total, pageIndex, pageSize }; diff --git a/src/actions/artworks/updateArtwork.ts b/src/actions/artworks/updateArtwork.ts index 0fb3325..d7917b0 100644 --- a/src/actions/artworks/updateArtwork.ts +++ b/src/actions/artworks/updateArtwork.ts @@ -47,7 +47,7 @@ export async function updateArtwork( const tagsRelation = tagIds || tagsToCreate.length ? { - tags: { + tagsV2: { set: [], // replace entire relation connect: (tagIds ?? []).map((tagId) => ({ id: tagId })), connectOrCreate: tagsToCreate.map((tName) => ({ @@ -94,4 +94,4 @@ export async function updateArtwork( }); return updatedArtwork; -} \ No newline at end of file +} diff --git a/src/actions/categories/deleteCategory.ts b/src/actions/categories/deleteCategory.ts index b5f4c62..8ced77a 100644 --- a/src/actions/categories/deleteCategory.ts +++ b/src/actions/categories/deleteCategory.ts @@ -10,7 +10,7 @@ export async function deleteCategory(catId: string) { id: true, _count: { select: { - tags: true, + tagLinks: true, artworks: true }, }, @@ -25,7 +25,7 @@ export async function deleteCategory(catId: string) { throw new Error("Cannot delete category: it is used by artworks."); } - if (cat._count.tags > 0) { + if (cat._count.tagLinks > 0) { throw new Error("Cannot delete category: it is used by tags."); } @@ -34,4 +34,4 @@ export async function deleteCategory(catId: string) { revalidatePath("/categories"); return { success: true }; -} \ No newline at end of file +} diff --git a/src/actions/categories/getCategories.ts b/src/actions/categories/getCategories.ts index 643e20e..5770b27 100644 --- a/src/actions/categories/getCategories.ts +++ b/src/actions/categories/getCategories.ts @@ -3,14 +3,17 @@ import { prisma } from "@/lib/prisma" export async function getCategoriesWithTags() { - return await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }) + return await prisma.artCategory.findMany({ + include: { tagLinks: { include: { tag: true } } }, + orderBy: { sortIndex: "asc" }, + }) } export async function getCategoriesWithCount() { return await prisma.artCategory.findMany({ include: { - _count: { select: { artworks: true, tags: true } }, + _count: { select: { artworks: true, tagLinks: true } }, }, orderBy: [{ sortIndex: "asc" }, { name: "asc" }], }) -} \ No newline at end of file +} diff --git a/src/actions/tags/createTag.ts b/src/actions/tags/createTag.ts index 073be71..6391d21 100644 --- a/src/actions/tags/createTag.ts +++ b/src/actions/tags/createTag.ts @@ -17,30 +17,30 @@ export async function createTag(formData: TagFormInput) { const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); const created = await prisma.$transaction(async (tx) => { - const tag = await tx.artTag.create({ + const tag = await tx.tag.create({ data: { name: data.name, slug: tagSlug, description: data.description, - isParent: data.isParent, - showOnAnimalPage: data.showOnAnimalPage, - parentId + isVisible: data.isVisible ?? true, }, }); if (data.categoryIds) { - await tx.artTag.update({ - where: { id: tag.id }, - data: { - categories: { - set: data.categoryIds.map(id => ({ id })) - } - } + await tx.tagCategory.createMany({ + data: data.categoryIds.map((categoryId) => ({ + tagId: tag.id, + categoryId, + isParent: data.isParent, + showOnAnimalPage: data.showOnAnimalPage, + parentTagId: parentId, + })), + skipDuplicates: true, }); } if (data.aliases && data.aliases.length > 0) { - await tx.artTagAlias.createMany({ + await tx.tagAlias.createMany({ data: data.aliases.map((alias) => ({ tagId: tag.id, alias, @@ -53,4 +53,4 @@ export async function createTag(formData: TagFormInput) { }); return created -} \ No newline at end of file +} diff --git a/src/actions/tags/deleteTag.ts b/src/actions/tags/deleteTag.ts index eef3c8e..1be1f83 100644 --- a/src/actions/tags/deleteTag.ts +++ b/src/actions/tags/deleteTag.ts @@ -4,14 +4,13 @@ import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; export async function deleteTag(tagId: string) { - const tag = await prisma.artTag.findUnique({ + const tag = await prisma.tag.findUnique({ where: { id: tagId }, select: { id: true, _count: { select: { artworks: true, - children: true, }, }, }, @@ -25,16 +24,21 @@ export async function deleteTag(tagId: string) { throw new Error("Cannot delete tag: it is used by artworks."); } - if (tag._count.children > 0) { + const parentUsage = await prisma.tagCategory.count({ + where: { parentTagId: tagId }, + }); + + if (parentUsage > 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 } }); + await tx.tagAlias.deleteMany({ where: { tagId } }); + await tx.tagCategory.deleteMany({ where: { tagId } }); + await tx.tag.delete({ where: { id: tagId } }); }); revalidatePath("/tags"); return { success: true }; -} \ No newline at end of file +} diff --git a/src/actions/tags/getTags.ts b/src/actions/tags/getTags.ts index 743bc59..9ef3316 100644 --- a/src/actions/tags/getTags.ts +++ b/src/actions/tags/getTags.ts @@ -3,5 +3,5 @@ import { prisma } from "@/lib/prisma" export async function getTags() { - return await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } }) -} \ No newline at end of file + return await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } }) +} diff --git a/src/actions/tags/isDescendant.ts b/src/actions/tags/isDescendant.ts index 34cb1bf..c4d4fa9 100644 --- a/src/actions/tags/isDescendant.ts +++ b/src/actions/tags/isDescendant.ts @@ -3,20 +3,26 @@ import { prisma } from "@/lib/prisma"; export async function isDescendant(tagId: string, possibleAncestorId: string): Promise { - // Walk upwards from possibleAncestorId; if we hit tagId, it's a cycle. - let current: string | null = possibleAncestorId; - - while (current) { + // Walk upwards across any category hierarchy; if we hit tagId, it's a cycle. + const visited = new Set(); + const queue: string[] = [possibleAncestorId]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) continue; if (current === tagId) return true; + if (visited.has(current)) continue; + visited.add(current); - const t: { parentId: string | null } | null = - await prisma.artTag.findUnique({ - where: { id: current }, - select: { parentId: true }, - }); + const parents = await prisma.tagCategory.findMany({ + where: { tagId: current, parentTagId: { not: null } }, + select: { parentTagId: true }, + }); - current = t?.parentId ?? null; + for (const p of parents) { + if (p.parentTagId) queue.push(p.parentTagId); + } } return false; -} \ No newline at end of file +} diff --git a/src/actions/tags/migrateArtTags.ts b/src/actions/tags/migrateArtTags.ts new file mode 100644 index 0000000..0c4837e --- /dev/null +++ b/src/actions/tags/migrateArtTags.ts @@ -0,0 +1,102 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export async function migrateArtTags() { + const artTags = await prisma.artTag.findMany({ + include: { + aliases: true, + categories: true, + artworks: { select: { id: true } }, + }, + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }); + + const idMap = new Map(); + + await prisma.$transaction(async (tx) => { + for (const artTag of artTags) { + const tag = await tx.tag.upsert({ + where: { slug: artTag.slug }, + update: { + name: artTag.name, + description: artTag.description, + isVisible: true, + }, + create: { + name: artTag.name, + slug: artTag.slug, + description: artTag.description, + isVisible: true, + }, + }); + + idMap.set(artTag.id, tag.id); + } + + const aliasRows = artTags.flatMap((artTag) => { + const tagId = idMap.get(artTag.id); + if (!tagId) return []; + return artTag.aliases.map((a) => ({ + tagId, + alias: a.alias, + })); + }); + + if (aliasRows.length > 0) { + await tx.tagAlias.createMany({ + data: aliasRows, + skipDuplicates: true, + }); + } + + const categoryRows = artTags.flatMap((artTag) => { + const tagId = idMap.get(artTag.id); + if (!tagId) return []; + const parentTagId = artTag.parentId + ? idMap.get(artTag.parentId) ?? null + : null; + + return artTag.categories.map((category) => ({ + tagId, + categoryId: category.id, + isParent: artTag.isParent, + showOnAnimalPage: artTag.showOnAnimalPage, + parentTagId, + })); + }); + + if (categoryRows.length > 0) { + await tx.tagCategory.createMany({ + data: categoryRows, + skipDuplicates: true, + }); + } + }); + + // Connect artwork relations outside the transaction to avoid timeouts. + for (const artTag of artTags) { + const tagId = idMap.get(artTag.id); + if (!tagId) continue; + + for (const artwork of artTag.artworks) { + await prisma.artwork.update({ + where: { id: artwork.id }, + data: { + tagsV2: { + connect: { id: tagId }, + }, + }, + }); + } + } + + const summary = { + tags: artTags.length, + aliases: artTags.reduce((sum, t) => sum + t.aliases.length, 0), + categoryLinks: artTags.reduce((sum, t) => sum + t.categories.length, 0), + }; + revalidatePath("/tags"); + return summary; +} diff --git a/src/actions/tags/updateTag.ts b/src/actions/tags/updateTag.ts index 5ae9b25..76d7539 100644 --- a/src/actions/tags/updateTag.ts +++ b/src/actions/tags/updateTag.ts @@ -27,22 +27,62 @@ export async function updateTag(id: string, rawData: TagFormInput) { } const updated = await prisma.$transaction(async (tx) => { - const tag = await tx.artTag.update({ + const tag = await tx.tag.update({ where: { id }, data: { name: data.name, slug: tagSlug, description: data.description, - isParent: data.isParent, - showOnAnimalPage: data.showOnAnimalPage, - parentId, - categories: data.categoryIds - ? { set: data.categoryIds.map((cid) => ({ id: cid })) } - : undefined, + isVisible: data.isVisible ?? true, }, }); - const existing = await tx.artTagAlias.findMany({ + if (data.categoryIds) { + const existingLinks = await tx.tagCategory.findMany({ + where: { tagId: id }, + select: { id: true, categoryId: true }, + }); + + const desired = new Set(data.categoryIds); + const existingSet = new Set(existingLinks.map((l) => l.categoryId)); + + const toCreate = data.categoryIds.filter((cid) => !existingSet.has(cid)); + const toDeleteIds = existingLinks + .filter((l) => !desired.has(l.categoryId)) + .map((l) => l.id); + + if (toDeleteIds.length > 0) { + await tx.tagCategory.deleteMany({ + where: { id: { in: toDeleteIds } }, + }); + } + + if (toCreate.length > 0) { + await tx.tagCategory.createMany({ + data: toCreate.map((categoryId) => ({ + tagId: id, + categoryId, + isParent: data.isParent, + showOnAnimalPage: data.showOnAnimalPage, + parentTagId: parentId, + })), + skipDuplicates: true, + }); + } + + if (existingLinks.length > 0) { + await tx.tagCategory.updateMany({ + where: { tagId: id, categoryId: { in: data.categoryIds } }, + data: { + isParent: data.isParent, + showOnAnimalPage: data.showOnAnimalPage, + parentTagId: parentId, + }, + }); + } + } + + const existing = await tx.tagAlias.findMany({ where: { tagId: id }, select: { id: true, alias: true }, }); @@ -56,13 +96,13 @@ export async function updateTag(id: string, rawData: TagFormInput) { .map((a) => a.id); if (toDeleteIds.length > 0) { - await tx.artTagAlias.deleteMany({ + await tx.tagAlias.deleteMany({ where: { id: { in: toDeleteIds } }, }); } if (toCreate.length > 0) { - await tx.artTagAlias.createMany({ + await tx.tagAlias.createMany({ data: toCreate.map((alias) => ({ tagId: id, alias })), skipDuplicates: true, }); @@ -72,4 +112,4 @@ export async function updateTag(id: string, rawData: TagFormInput) { }); return updated -} \ No newline at end of file +} diff --git a/src/app/(admin)/tags/[id]/page.tsx b/src/app/(admin)/tags/[id]/page.tsx index 8145fb4..76ed05b 100644 --- a/src/app/(admin)/tags/[id]/page.tsx +++ b/src/app/(admin)/tags/[id]/page.tsx @@ -3,23 +3,43 @@ import { prisma } from "@/lib/prisma"; export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) { const { id } = await params; - const tag = await prisma.artTag.findUnique({ + const tag = await prisma.tag.findUnique({ where: { id, }, include: { - categories: true, + categoryLinks: { + include: { + category: true, + parentTag: { select: { id: true, name: true } }, + }, + }, aliases: true } }) - const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }); - const tags = await prisma.artTag.findMany({ where: { isParent: true }, orderBy: { sortIndex: "asc" } }); + const categories = await prisma.artCategory.findMany({ + include: { tagLinks: true }, + orderBy: { sortIndex: "asc" }, + }); + const tags = await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } }); + + const animalLink = + tag?.categoryLinks.find((link) => link.category.name === "Animal Studies") ?? + null; + const tagWithMeta = tag + ? { + ...tag, + parentId: animalLink?.parentTagId ?? null, + isParent: animalLink?.isParent ?? false, + showOnAnimalPage: animalLink?.showOnAnimalPage ?? false, + } + : null; return (

Edit Tag

- {tag && } + {tagWithMeta && }
); -} \ No newline at end of file +} diff --git a/src/app/(admin)/tags/new/page.tsx b/src/app/(admin)/tags/new/page.tsx index 9e37e9b..3cd69ca 100644 --- a/src/app/(admin)/tags/new/page.tsx +++ b/src/app/(admin)/tags/new/page.tsx @@ -2,8 +2,11 @@ import NewTagForm from "@/components/tags/NewTagForm"; 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({ where: { isParent: true }, orderBy: { sortIndex: "asc" } }); + const categories = await prisma.artCategory.findMany({ + include: { tagLinks: true }, + orderBy: { sortIndex: "asc" }, + }); + const tags = await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } }); return (
@@ -11,4 +14,4 @@ export default async function PortfolioTagsNewPage() {
); -} \ No newline at end of file +} diff --git a/src/app/(admin)/tags/page.tsx b/src/app/(admin)/tags/page.tsx index ca96d2c..4828010 100644 --- a/src/app/(admin)/tags/page.tsx +++ b/src/app/(admin)/tags/page.tsx @@ -3,40 +3,71 @@ import { Button } from "@/components/ui/button"; import { prisma } from "@/lib/prisma"; import { PlusCircleIcon } from "lucide-react"; import Link from "next/link"; +import { migrateArtTags } from "@/actions/tags/migrateArtTags"; export default async function ArtTagsPage() { - const items = await prisma.artTag.findMany({ + const items = await prisma.tag.findMany({ include: { - parent: { select: { id: true, name: true } }, aliases: { select: { alias: true } }, - categories: { select: { id: true, name: true } }, + categoryLinks: { + include: { + category: { select: { id: true, name: true } }, + parentTag: { select: { id: true, name: true } }, + }, + }, _count: { select: { artworks: true } }, }, orderBy: [{ sortIndex: "asc" }, { name: "asc" }], }); + const rows = items.map((tag) => { + const categories = tag.categoryLinks.map((link) => link.category); + const animalLink = tag.categoryLinks.find( + (link) => link.category.name === "Animal Studies", + ); + + return { + id: tag.id, + name: tag.name, + slug: tag.slug, + parent: animalLink?.parentTag ?? null, + isParent: animalLink?.isParent ?? false, + showOnAnimalPage: animalLink?.showOnAnimalPage ?? false, + aliases: tag.aliases, + categories, + _count: tag._count, + }; + }); + return (

- Art Tags + Tags

Manage tags, aliases, categories, and usage across artworks.

- +
+
+ +
+ +
- {items.length > 0 ? ( - + {rows.length > 0 ? ( + ) : (

There are no tags yet. Consider adding some! @@ -44,4 +75,4 @@ export default async function ArtTagsPage() { )}

); -} \ No newline at end of file +} diff --git a/src/components/artworks/single/ArtworkDetails.tsx b/src/components/artworks/single/ArtworkDetails.tsx index 7835c06..2745e2c 100644 --- a/src/components/artworks/single/ArtworkDetails.tsx +++ b/src/components/artworks/single/ArtworkDetails.tsx @@ -171,7 +171,7 @@ export default function ArtworkDetails({ v: (
{(artwork.categories?.length ?? 0)} categories - {(artwork.tags?.length ?? 0)} tags + {(artwork.tagsV2?.length ?? 0)} tags {(artwork.colors?.length ?? 0)} colors {(artwork.variants?.length ?? 0)} variants
diff --git a/src/components/artworks/single/EditArtworkForm.tsx b/src/components/artworks/single/EditArtworkForm.tsx index 1f1db5d..021e901 100644 --- a/src/components/artworks/single/EditArtworkForm.tsx +++ b/src/components/artworks/single/EditArtworkForm.tsx @@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"; import MultipleSelector from "@/components/ui/multiselect"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import type { ArtTag } from "@/generated/prisma/client"; +import type { Tag } from "@/generated/prisma/client"; import { artworkSchema } from "@/schemas/artworks/imageSchema"; import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -20,7 +20,7 @@ export default function EditArtworkForm({ artwork, categories, tags }: { artwork: ArtworkWithRelations, categories: CategoryWithTags[] - tags: ArtTag[] + tags: Tag[] }) { const router = useRouter(); const form = useForm>({ @@ -39,7 +39,7 @@ export default function EditArtworkForm({ artwork, categories, tags }: year: artwork.year || undefined, creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined, categoryIds: artwork.categories?.map(cat => cat.id) ?? [], - tagIds: artwork.tags?.map(tag => tag.id) ?? [], + tagIds: artwork.tagsV2?.map(tag => tag.id) ?? [], newCategoryNames: [], newTagNames: [] } @@ -264,7 +264,7 @@ export default function EditArtworkForm({ artwork, categories, tags }: const preferredTagIds = new Set(); for (const cat of categories) { if (!selectedCategoryIds.includes(cat.id)) continue; - for (const t of cat.tags) preferredTagIds.add(t.id); + for (const link of cat.tagLinks) preferredTagIds.add(link.tagId); } // Existing tag options with groups @@ -402,4 +402,4 @@ export default function EditArtworkForm({ artwork, categories, tags }: ); -} \ No newline at end of file +} diff --git a/src/components/categories/CategoryTable.tsx b/src/components/categories/CategoryTable.tsx index 6abe228..1c234f0 100644 --- a/src/components/categories/CategoryTable.tsx +++ b/src/components/categories/CategoryTable.tsx @@ -17,7 +17,7 @@ type CatRow = { id: string; name: string; slug: string; - _count: { artworks: number, tags: number }; + _count: { artworks: number, tagLinks: number }; }; export default function CategoryTable({ categories }: { categories: CatRow[] }) { @@ -48,7 +48,7 @@ export default function CategoryTable({ categories }: { categories: CatRow[] }) - {c._count.tags} + {c._count.tagLinks} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index fddafd0..d5e4979 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -19,6 +19,9 @@ const artworkItems = [ title: "Categories", href: "/categories", }, +] + +const topicItems = [ { title: "Tags", href: "/tags", @@ -110,6 +113,25 @@ export default function TopNav() { + + Topics + +
    + {topicItems.map((item) => ( +
  • + + +
    {item.title}
    +

    +

    + +
    +
  • + ))} +
+
+
+ Commissions diff --git a/src/components/global/nav.ts b/src/components/global/nav.ts index d88edf8..b6f244d 100644 --- a/src/components/global/nav.ts +++ b/src/components/global/nav.ts @@ -34,10 +34,15 @@ export const adminNav: AdminNavGroup[] = [ title: "Artwork Management", items: [ { title: "Categories", href: "/categories" }, - { title: "Tags", href: "/tags" }, ], }, + { + type: "group", + title: "Topics", + items: [{ title: "Tags", href: "/tags" }], + }, + { type: "group", title: "Commissions", diff --git a/src/components/tags/EditTagForm.tsx b/src/components/tags/EditTagForm.tsx index dce3b05..06eb048 100644 --- a/src/components/tags/EditTagForm.tsx +++ b/src/components/tags/EditTagForm.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { ArtCategory, ArtTag, ArtTagAlias } from "@/generated/prisma/client"; +import { ArtCategory, Tag, TagAlias } from "@/generated/prisma/client"; import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; @@ -16,17 +16,32 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { Switch } from "../ui/switch"; import AliasEditor from "./AliasEditor"; -export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag & { categories: ArtCategory[], aliases: ArtTagAlias[] }, categories: ArtCategory[], allTags: ArtTag[] }) { +export default function EditTagForm({ + tag, + categories, + allTags, +}: { + tag: Tag & { + categoryLinks: { category: ArtCategory }[]; + aliases: TagAlias[]; + parentId?: string | null; + isParent?: boolean; + showOnAnimalPage?: boolean; + }; + categories: ArtCategory[]; + allTags: Tag[]; +}) { const router = useRouter(); const form = useForm({ resolver: zodResolver(tagSchema), defaultValues: { name: tag.name, description: tag.description || "", - categoryIds: tag.categories?.map(cat => cat.id) ?? [], - parentId: (tag as any).parentId ?? null, + categoryIds: tag.categoryLinks?.map((link) => link.category.id) ?? [], + parentId: tag.parentId ?? null, isParent: tag.isParent ?? false, showOnAnimalPage: tag.showOnAnimalPage ?? false, + isVisible: tag.isVisible ?? true, aliases: tag.aliases?.map(a => a.alias) ?? [] } }) @@ -34,12 +49,12 @@ export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag async function onSubmit(values: TagFormInput) { try { const updated = await updateTag(tag.id, values) - console.log("Art tag updated:", updated) - toast("Art tag updated.") + console.log("Tag updated:", updated) + toast("Tag updated.") router.push("/tags") } catch (err) { console.error(err) - toast("Failed to update art tag.") + toast("Failed to update tag.") } } @@ -184,6 +199,23 @@ export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag )} /> +
+ ( + +
+ Visible + +
+ + + +
+ )} + /> +
@@ -192,4 +224,4 @@ export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag
); -} \ No newline at end of file +} diff --git a/src/components/tags/NewTagForm.tsx b/src/components/tags/NewTagForm.tsx index b1bffd4..bcee8df 100644 --- a/src/components/tags/NewTagForm.tsx +++ b/src/components/tags/NewTagForm.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, 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 { ArtCategory, Tag } from "@/generated/prisma/client"; import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/navigation"; @@ -17,7 +17,7 @@ import { Switch } from "../ui/switch"; import AliasEditor from "./AliasEditor"; -export default function NewTagForm({ categories, allTags }: { categories: ArtCategory[], allTags: ArtTag[] }) { +export default function NewTagForm({ categories, allTags }: { categories: ArtCategory[], allTags: Tag[] }) { const router = useRouter(); const form = useForm({ resolver: zodResolver(tagSchema), @@ -28,6 +28,7 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat parentId: null, isParent: false, showOnAnimalPage: false, + isVisible: true, aliases: [], } }) @@ -35,12 +36,12 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat async function onSubmit(values: TagFormInput) { try { const created = await createTag(values) - console.log("Art tag created:", created) - toast("Art tag created.") + console.log("Tag created:", created) + toast("Tag created.") router.push("/tags") } catch (err) { console.error(err) - toast("Failed to create art tag.") + toast("Failed to create tag.") } } @@ -185,6 +186,23 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat )} /> +
+ ( + +
+ Visible + +
+ + + +
+ )} + /> +
@@ -193,4 +211,4 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
); -} \ No newline at end of file +} diff --git a/src/lib/queryArtworks.ts b/src/lib/queryArtworks.ts index 3947712..5999e8b 100644 --- a/src/lib/queryArtworks.ts +++ b/src/lib/queryArtworks.ts @@ -26,7 +26,7 @@ export async function getArtworksPage(params: ArtworkListParams) { albums: true, categories: true, colors: true, - tags: true, + tagsV2: true, variants: true, }, orderBy: [{ createdAt: "desc" }, { id: "asc" }], diff --git a/src/schemas/artworks/tagSchema.ts b/src/schemas/artworks/tagSchema.ts index 62fce1c..30af2ae 100644 --- a/src/schemas/artworks/tagSchema.ts +++ b/src/schemas/artworks/tagSchema.ts @@ -7,6 +7,7 @@ export const tagSchema = z.object({ parentId: z.string().nullable().optional(), isParent: z.boolean(), showOnAnimalPage: z.boolean(), + isVisible: z.boolean().default(true), aliases: z .array(z.string().trim().min(1)) @@ -19,4 +20,3 @@ export const tagSchema = z.object({ export type TagFormInput = z.input; export type TagFormOutput = z.output; - diff --git a/src/types/Artwork.ts b/src/types/Artwork.ts index 16b9dde..aba94ea 100644 --- a/src/types/Artwork.ts +++ b/src/types/Artwork.ts @@ -8,12 +8,12 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{ albums: true; categories: true; colors: true; - tags: true; + tagsV2: true; variants: true; timelapse: true; }; }>; export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{ - include: { tags: true }; -}>; \ No newline at end of file + include: { tagLinks: { include: { tag: true } } }; +}>;