Moving the arttags table to tags table part 1

This commit is contained in:
2026-02-02 13:05:52 +01:00
parent 6680ccc023
commit 7605ccb0aa
27 changed files with 604 additions and 107 deletions

View File

@ -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;

View File

@ -54,6 +54,7 @@ model Artwork {
categories ArtCategory[] categories ArtCategory[]
colors ArtworkColor[] colors ArtworkColor[]
tags ArtTag[] tags ArtTag[]
tagsV2 Tag[] @relation("ArtworkTagsV2")
variants FileVariant[] variants FileVariant[]
@@index([colorStatus]) @@index([colorStatus])
@ -102,6 +103,7 @@ model ArtCategory {
artworks Artwork[] artworks Artwork[]
tags ArtTag[] tags ArtTag[]
tagLinks TagCategory[]
} }
model ArtTag { model ArtTag {
@ -126,6 +128,63 @@ model ArtTag {
children ArtTag[] @relation("TagHierarchy") 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 { model ArtTagAlias {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -140,6 +199,14 @@ model ArtTagAlias {
@@index([alias]) @@index([alias])
} }
model Miniature {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags Tag[] @relation("MiniatureTags")
}
model Color { model Color {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -265,6 +332,8 @@ model CommissionType {
description String? description String?
tags Tag[] @relation("CommissionTypeTags")
options CommissionTypeOption[] options CommissionTypeOption[]
extras CommissionTypeExtra[] extras CommissionTypeExtra[]
customInputs CommissionTypeCustomInput[] customInputs CommissionTypeCustomInput[]

View File

@ -13,6 +13,7 @@ export async function deleteArtwork(artworkId: string) {
colors: true, colors: true,
metadata: true, metadata: true,
tags: true, tags: true,
tagsV2: true,
categories: true, categories: true,
}, },
}); });
@ -74,6 +75,7 @@ export async function deleteArtwork(artworkId: string) {
where: { id: artworkId }, where: { id: artworkId },
data: { data: {
tags: { set: [] }, tags: { set: [] },
tagsV2: { set: [] },
categories: { set: [] }, categories: { set: [] },
}, },
}); });
@ -82,4 +84,4 @@ export async function deleteArtwork(artworkId: string) {
await prisma.artwork.delete({ where: { id: artworkId } }); await prisma.artwork.delete({ where: { id: artworkId } });
return { success: true }; return { success: true };
} }

View File

@ -15,9 +15,9 @@ export async function getSingleArtwork(id: string) {
categories: true, categories: true,
colors: { include: { color: true } }, colors: { include: { color: true } },
// sortContexts: true, // sortContexts: true,
tags: true, tagsV2: true,
variants: true, variants: true,
timelapse: true timelapse: true
} }
}) })
} }

View File

@ -22,7 +22,7 @@ function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) {
// relation counts: Prisma supports ordering by _count // relation counts: Prisma supports ordering by _count
albumsCount: (desc) => ({ albums: { _count: desc ? "desc" : "asc" } }), albumsCount: (desc) => ({ albums: { _count: desc ? "desc" : "asc" } }),
categoriesCount: (desc) => ({ categories: { _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 const orderBy = sorting
@ -89,7 +89,7 @@ export async function getArtworksTablePage(input: unknown) {
gallery: { select: { id: true, name: true } }, gallery: { select: { id: true, name: true } },
albums: { select: { id: true, name: true } }, albums: { select: { id: true, name: true } },
categories: { 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, categories: a.categories,
albumsCount: a._count.albums, albumsCount: a._count.albums,
categoriesCount: a._count.categories, categoriesCount: a._count.categories,
tagsCount: a._count.tags, tagsCount: a._count.tagsV2,
})); }));
const out = { rows, total, pageIndex, pageSize }; const out = { rows, total, pageIndex, pageSize };

View File

@ -47,7 +47,7 @@ export async function updateArtwork(
const tagsRelation = const tagsRelation =
tagIds || tagsToCreate.length tagIds || tagsToCreate.length
? { ? {
tags: { tagsV2: {
set: [], // replace entire relation set: [], // replace entire relation
connect: (tagIds ?? []).map((tagId) => ({ id: tagId })), connect: (tagIds ?? []).map((tagId) => ({ id: tagId })),
connectOrCreate: tagsToCreate.map((tName) => ({ connectOrCreate: tagsToCreate.map((tName) => ({
@ -94,4 +94,4 @@ export async function updateArtwork(
}); });
return updatedArtwork; return updatedArtwork;
} }

View File

@ -10,7 +10,7 @@ export async function deleteCategory(catId: string) {
id: true, id: true,
_count: { _count: {
select: { select: {
tags: true, tagLinks: true,
artworks: true artworks: true
}, },
}, },
@ -25,7 +25,7 @@ export async function deleteCategory(catId: string) {
throw new Error("Cannot delete category: it is used by artworks."); 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."); throw new Error("Cannot delete category: it is used by tags.");
} }
@ -34,4 +34,4 @@ export async function deleteCategory(catId: string) {
revalidatePath("/categories"); revalidatePath("/categories");
return { success: true }; return { success: true };
} }

View File

@ -3,14 +3,17 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
export async function getCategoriesWithTags() { 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() { export async function getCategoriesWithCount() {
return await prisma.artCategory.findMany({ return await prisma.artCategory.findMany({
include: { include: {
_count: { select: { artworks: true, tags: true } }, _count: { select: { artworks: true, tagLinks: true } },
}, },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}) })
} }

View File

@ -17,30 +17,30 @@ export async function createTag(formData: TagFormInput) {
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-"); const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
const created = await prisma.$transaction(async (tx) => { const created = await prisma.$transaction(async (tx) => {
const tag = await tx.artTag.create({ const tag = await tx.tag.create({
data: { data: {
name: data.name, name: data.name,
slug: tagSlug, slug: tagSlug,
description: data.description, description: data.description,
isParent: data.isParent, isVisible: data.isVisible ?? true,
showOnAnimalPage: data.showOnAnimalPage,
parentId
}, },
}); });
if (data.categoryIds) { if (data.categoryIds) {
await tx.artTag.update({ await tx.tagCategory.createMany({
where: { id: tag.id }, data: data.categoryIds.map((categoryId) => ({
data: { tagId: tag.id,
categories: { categoryId,
set: data.categoryIds.map(id => ({ id })) isParent: data.isParent,
} showOnAnimalPage: data.showOnAnimalPage,
} parentTagId: parentId,
})),
skipDuplicates: true,
}); });
} }
if (data.aliases && data.aliases.length > 0) { if (data.aliases && data.aliases.length > 0) {
await tx.artTagAlias.createMany({ await tx.tagAlias.createMany({
data: data.aliases.map((alias) => ({ data: data.aliases.map((alias) => ({
tagId: tag.id, tagId: tag.id,
alias, alias,
@ -53,4 +53,4 @@ export async function createTag(formData: TagFormInput) {
}); });
return created return created
} }

View File

@ -4,14 +4,13 @@ import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export async function deleteTag(tagId: string) { export async function deleteTag(tagId: string) {
const tag = await prisma.artTag.findUnique({ const tag = await prisma.tag.findUnique({
where: { id: tagId }, where: { id: tagId },
select: { select: {
id: true, id: true,
_count: { _count: {
select: { select: {
artworks: true, 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."); 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."); throw new Error("Cannot delete tag: it has child tags.");
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.artTagAlias.deleteMany({ where: { tagId } }); await tx.tagAlias.deleteMany({ where: { tagId } });
await tx.artTag.delete({ where: { id: tagId } }); await tx.tagCategory.deleteMany({ where: { tagId } });
await tx.tag.delete({ where: { id: tagId } });
}); });
revalidatePath("/tags"); revalidatePath("/tags");
return { success: true }; return { success: true };
} }

View File

@ -3,5 +3,5 @@
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
export async function getTags() { export async function getTags() {
return await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } }) return await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } })
} }

View File

@ -3,20 +3,26 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export async function isDescendant(tagId: string, possibleAncestorId: string): Promise<boolean> { export async function isDescendant(tagId: string, possibleAncestorId: string): Promise<boolean> {
// Walk upwards from possibleAncestorId; if we hit tagId, it's a cycle. // Walk upwards across any category hierarchy; if we hit tagId, it's a cycle.
let current: string | null = possibleAncestorId; const visited = new Set<string>();
const queue: string[] = [possibleAncestorId];
while (current) {
while (queue.length > 0) {
const current = queue.shift();
if (!current) continue;
if (current === tagId) return true; if (current === tagId) return true;
if (visited.has(current)) continue;
visited.add(current);
const t: { parentId: string | null } | null = const parents = await prisma.tagCategory.findMany({
await prisma.artTag.findUnique({ where: { tagId: current, parentTagId: { not: null } },
where: { id: current }, select: { parentTagId: true },
select: { parentId: true }, });
});
current = t?.parentId ?? null; for (const p of parents) {
if (p.parentTagId) queue.push(p.parentTagId);
}
} }
return false; return false;
} }

View File

@ -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<string, string>();
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;
}

View File

@ -27,22 +27,62 @@ export async function updateTag(id: string, rawData: TagFormInput) {
} }
const updated = await prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
const tag = await tx.artTag.update({ const tag = await tx.tag.update({
where: { id }, where: { id },
data: { data: {
name: data.name, name: data.name,
slug: tagSlug, slug: tagSlug,
description: data.description, description: data.description,
isParent: data.isParent, isVisible: data.isVisible ?? true,
showOnAnimalPage: data.showOnAnimalPage,
parentId,
categories: data.categoryIds
? { set: data.categoryIds.map((cid) => ({ id: cid })) }
: undefined,
}, },
}); });
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 }, where: { tagId: id },
select: { id: true, alias: true }, select: { id: true, alias: true },
}); });
@ -56,13 +96,13 @@ export async function updateTag(id: string, rawData: TagFormInput) {
.map((a) => a.id); .map((a) => a.id);
if (toDeleteIds.length > 0) { if (toDeleteIds.length > 0) {
await tx.artTagAlias.deleteMany({ await tx.tagAlias.deleteMany({
where: { id: { in: toDeleteIds } }, where: { id: { in: toDeleteIds } },
}); });
} }
if (toCreate.length > 0) { if (toCreate.length > 0) {
await tx.artTagAlias.createMany({ await tx.tagAlias.createMany({
data: toCreate.map((alias) => ({ tagId: id, alias })), data: toCreate.map((alias) => ({ tagId: id, alias })),
skipDuplicates: true, skipDuplicates: true,
}); });
@ -72,4 +112,4 @@ export async function updateTag(id: string, rawData: TagFormInput) {
}); });
return updated return updated
} }

View File

@ -3,23 +3,43 @@ import { prisma } from "@/lib/prisma";
export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) { export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) {
const { id } = await params; const { id } = await params;
const tag = await prisma.artTag.findUnique({ const tag = await prisma.tag.findUnique({
where: { where: {
id, id,
}, },
include: { include: {
categories: true, categoryLinks: {
include: {
category: true,
parentTag: { select: { id: true, name: true } },
},
},
aliases: true aliases: true
} }
}) })
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }); const categories = await prisma.artCategory.findMany({
const tags = await prisma.artTag.findMany({ where: { isParent: true }, orderBy: { sortIndex: "asc" } }); 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 ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Edit Tag</h1> <h1 className="text-2xl font-bold mb-4">Edit Tag</h1>
{tag && <EditTagForm tag={tag} categories={categories} allTags={tags} />} {tagWithMeta && <EditTagForm tag={tagWithMeta} categories={categories} allTags={tags} />}
</div> </div>
); );
} }

View File

@ -2,8 +2,11 @@ import NewTagForm from "@/components/tags/NewTagForm";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export default async function PortfolioTagsNewPage() { export default async function PortfolioTagsNewPage() {
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } }); const categories = await prisma.artCategory.findMany({
const tags = await prisma.artTag.findMany({ where: { isParent: true }, orderBy: { sortIndex: "asc" } }); include: { tagLinks: true },
orderBy: { sortIndex: "asc" },
});
const tags = await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } });
return ( return (
<div> <div>
@ -11,4 +14,4 @@ export default async function PortfolioTagsNewPage() {
<NewTagForm categories={categories} allTags={tags} /> <NewTagForm categories={categories} allTags={tags} />
</div> </div>
); );
} }

View File

@ -3,40 +3,71 @@ import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react"; import { PlusCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { migrateArtTags } from "@/actions/tags/migrateArtTags";
export default async function ArtTagsPage() { export default async function ArtTagsPage() {
const items = await prisma.artTag.findMany({ const items = await prisma.tag.findMany({
include: { include: {
parent: { select: { id: true, name: true } },
aliases: { select: { alias: 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 } }, _count: { select: { artworks: true } },
}, },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], 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 ( return (
<div className="mx-auto w-full max-w-7xl px-4 py-6"> <div className="mx-auto w-full max-w-7xl px-4 py-6">
<header className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <header className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl"> <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Art Tags Tags
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Manage tags, aliases, categories, and usage across artworks. Manage tags, aliases, categories, and usage across artworks.
</p> </p>
</div> </div>
<Button asChild className="h-11 gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Link href="/tags/new"> <form action={migrateArtTags}>
<PlusCircleIcon className="h-4 w-4" /> <Button type="submit" variant="secondary" className="h-11">
Add new tag Migrate old tags
</Link> </Button>
</Button> </form>
<Button asChild className="h-11 gap-2">
<Link href="/tags/new">
<PlusCircleIcon className="h-4 w-4" />
Add new tag
</Link>
</Button>
</div>
</header> </header>
{items.length > 0 ? ( {rows.length > 0 ? (
<TagTabs tags={items} /> <TagTabs tags={rows} />
) : ( ) : (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
There are no tags yet. Consider adding some! There are no tags yet. Consider adding some!
@ -44,4 +75,4 @@ export default async function ArtTagsPage() {
)} )}
</div> </div>
); );
} }

View File

@ -171,7 +171,7 @@ export default function ArtworkDetails({
v: ( v: (
<div className="flex flex-wrap gap-2 text-xs"> <div className="flex flex-wrap gap-2 text-xs">
<Badge variant="secondary">{(artwork.categories?.length ?? 0)} categories</Badge> <Badge variant="secondary">{(artwork.categories?.length ?? 0)} categories</Badge>
<Badge variant="secondary">{(artwork.tags?.length ?? 0)} tags</Badge> <Badge variant="secondary">{(artwork.tagsV2?.length ?? 0)} tags</Badge>
<Badge variant="secondary">{(artwork.colors?.length ?? 0)} colors</Badge> <Badge variant="secondary">{(artwork.colors?.length ?? 0)} colors</Badge>
<Badge variant="secondary">{(artwork.variants?.length ?? 0)} variants</Badge> <Badge variant="secondary">{(artwork.variants?.length ?? 0)} variants</Badge>
</div> </div>

View File

@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect"; import MultipleSelector from "@/components/ui/multiselect";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; 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 { artworkSchema } from "@/schemas/artworks/imageSchema";
import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork"; import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -20,7 +20,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
{ {
artwork: ArtworkWithRelations, artwork: ArtworkWithRelations,
categories: CategoryWithTags[] categories: CategoryWithTags[]
tags: ArtTag[] tags: Tag[]
}) { }) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof artworkSchema>>({ const form = useForm<z.infer<typeof artworkSchema>>({
@ -39,7 +39,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
year: artwork.year || undefined, year: artwork.year || undefined,
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined, creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
categoryIds: artwork.categories?.map(cat => cat.id) ?? [], categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
tagIds: artwork.tags?.map(tag => tag.id) ?? [], tagIds: artwork.tagsV2?.map(tag => tag.id) ?? [],
newCategoryNames: [], newCategoryNames: [],
newTagNames: [] newTagNames: []
} }
@ -264,7 +264,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
const preferredTagIds = new Set<string>(); const preferredTagIds = new Set<string>();
for (const cat of categories) { for (const cat of categories) {
if (!selectedCategoryIds.includes(cat.id)) continue; 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 // Existing tag options with groups
@ -402,4 +402,4 @@ export default function EditArtworkForm({ artwork, categories, tags }:
</Form> </Form>
</div > </div >
); );
} }

View File

@ -17,7 +17,7 @@ type CatRow = {
id: string; id: string;
name: string; name: string;
slug: string; slug: string;
_count: { artworks: number, tags: number }; _count: { artworks: number, tagLinks: number };
}; };
export default function CategoryTable({ categories }: { categories: CatRow[] }) { export default function CategoryTable({ categories }: { categories: CatRow[] }) {
@ -48,7 +48,7 @@ export default function CategoryTable({ categories }: { categories: CatRow[] })
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{c._count.tags} {c._count.tagLinks}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">

View File

@ -19,6 +19,9 @@ const artworkItems = [
title: "Categories", title: "Categories",
href: "/categories", href: "/categories",
}, },
]
const topicItems = [
{ {
title: "Tags", title: "Tags",
href: "/tags", href: "/tags",
@ -110,6 +113,25 @@ export default function TopNav() {
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Topics</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-50 gap-4">
{topicItems.map((item) => (
<li key={item.title}>
<NavigationMenuLink asChild>
<Link href={item.href}>
<div className="text-sm leading-none font-medium">{item.title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
</p>
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuTrigger>Commissions</NavigationMenuTrigger> <NavigationMenuTrigger>Commissions</NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>

View File

@ -34,10 +34,15 @@ export const adminNav: AdminNavGroup[] = [
title: "Artwork Management", title: "Artwork Management",
items: [ items: [
{ title: "Categories", href: "/categories" }, { title: "Categories", href: "/categories" },
{ title: "Tags", href: "/tags" },
], ],
}, },
{
type: "group",
title: "Topics",
items: [{ title: "Tags", href: "/tags" }],
},
{ {
type: "group", type: "group",
title: "Commissions", title: "Commissions",

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; 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 { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -16,17 +16,32 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import AliasEditor from "./AliasEditor"; 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 router = useRouter();
const form = useForm<TagFormInput>({ const form = useForm<TagFormInput>({
resolver: zodResolver(tagSchema), resolver: zodResolver(tagSchema),
defaultValues: { defaultValues: {
name: tag.name, name: tag.name,
description: tag.description || "", description: tag.description || "",
categoryIds: tag.categories?.map(cat => cat.id) ?? [], categoryIds: tag.categoryLinks?.map((link) => link.category.id) ?? [],
parentId: (tag as any).parentId ?? null, parentId: tag.parentId ?? null,
isParent: tag.isParent ?? false, isParent: tag.isParent ?? false,
showOnAnimalPage: tag.showOnAnimalPage ?? false, showOnAnimalPage: tag.showOnAnimalPage ?? false,
isVisible: tag.isVisible ?? true,
aliases: tag.aliases?.map(a => a.alias) ?? [] 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) { async function onSubmit(values: TagFormInput) {
try { try {
const updated = await updateTag(tag.id, values) const updated = await updateTag(tag.id, values)
console.log("Art tag updated:", updated) console.log("Tag updated:", updated)
toast("Art tag updated.") toast("Tag updated.")
router.push("/tags") router.push("/tags")
} catch (err) { } catch (err) {
console.error(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
)} )}
/> />
</div> </div>
<div className="flex">
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible</FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
@ -192,4 +224,4 @@ export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag
</Form> </Form>
</div > </div >
); );
} }

View File

@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; 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 { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -17,7 +17,7 @@ import { Switch } from "../ui/switch";
import AliasEditor from "./AliasEditor"; 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 router = useRouter();
const form = useForm<TagFormInput>({ const form = useForm<TagFormInput>({
resolver: zodResolver(tagSchema), resolver: zodResolver(tagSchema),
@ -28,6 +28,7 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
parentId: null, parentId: null,
isParent: false, isParent: false,
showOnAnimalPage: false, showOnAnimalPage: false,
isVisible: true,
aliases: [], aliases: [],
} }
}) })
@ -35,12 +36,12 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
async function onSubmit(values: TagFormInput) { async function onSubmit(values: TagFormInput) {
try { try {
const created = await createTag(values) const created = await createTag(values)
console.log("Art tag created:", created) console.log("Tag created:", created)
toast("Art tag created.") toast("Tag created.")
router.push("/tags") router.push("/tags")
} catch (err) { } catch (err) {
console.error(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
)} )}
/> />
</div> </div>
<div className="flex">
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible</FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
@ -193,4 +211,4 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
</Form> </Form>
</div > </div >
); );
} }

View File

@ -26,7 +26,7 @@ export async function getArtworksPage(params: ArtworkListParams) {
albums: true, albums: true,
categories: true, categories: true,
colors: true, colors: true,
tags: true, tagsV2: true,
variants: true, variants: true,
}, },
orderBy: [{ createdAt: "desc" }, { id: "asc" }], orderBy: [{ createdAt: "desc" }, { id: "asc" }],

View File

@ -7,6 +7,7 @@ export const tagSchema = z.object({
parentId: z.string().nullable().optional(), parentId: z.string().nullable().optional(),
isParent: z.boolean(), isParent: z.boolean(),
showOnAnimalPage: z.boolean(), showOnAnimalPage: z.boolean(),
isVisible: z.boolean().default(true),
aliases: z aliases: z
.array(z.string().trim().min(1)) .array(z.string().trim().min(1))
@ -19,4 +20,3 @@ export const tagSchema = z.object({
export type TagFormInput = z.input<typeof tagSchema>; export type TagFormInput = z.input<typeof tagSchema>;
export type TagFormOutput = z.output<typeof tagSchema>; export type TagFormOutput = z.output<typeof tagSchema>;

View File

@ -8,12 +8,12 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
albums: true; albums: true;
categories: true; categories: true;
colors: true; colors: true;
tags: true; tagsV2: true;
variants: true; variants: true;
timelapse: true; timelapse: true;
}; };
}>; }>;
export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{ export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{
include: { tags: true }; include: { tagLinks: { include: { tag: true } } };
}>; }>;