Moving the arttags table to tags table part 2

This commit is contained in:
2026-02-02 13:48:49 +01:00
parent 7605ccb0aa
commit ed81662ae5
13 changed files with 195 additions and 245 deletions

View File

@ -53,8 +53,7 @@ model Artwork {
albums Album[] albums Album[]
categories ArtCategory[] categories ArtCategory[]
colors ArtworkColor[] colors ArtworkColor[]
tags ArtTag[] tags Tag[] @relation("ArtworkTags")
tagsV2 Tag[] @relation("ArtworkTagsV2")
variants FileVariant[] variants FileVariant[]
@@index([colorStatus]) @@index([colorStatus])
@ -102,111 +101,9 @@ model ArtCategory {
description String? description String?
artworks Artwork[] artworks Artwork[]
tags ArtTag[]
tagLinks TagCategory[] tagLinks TagCategory[]
} }
model ArtTag {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
isParent Boolean @default(false)
showOnAnimalPage Boolean @default(false)
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 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())
updatedAt DateTime @updatedAt
alias String @unique
tagId String
tag ArtTag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([tagId, 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())
@ -315,6 +212,71 @@ model FileVariant {
@@unique([artworkId, type]) @@unique([artworkId, type])
} }
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("ArtworkTags")
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 Miniature {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags Tag[] @relation("MiniatureTags")
}
model Commission { model Commission {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -13,7 +13,6 @@ export async function deleteArtwork(artworkId: string) {
colors: true, colors: true,
metadata: true, metadata: true,
tags: true, tags: true,
tagsV2: true,
categories: true, categories: true,
}, },
}); });
@ -75,7 +74,6 @@ export async function deleteArtwork(artworkId: string) {
where: { id: artworkId }, where: { id: artworkId },
data: { data: {
tags: { set: [] }, tags: { set: [] },
tagsV2: { set: [] },
categories: { set: [] }, categories: { set: [] },
}, },
}); });

View File

@ -15,7 +15,7 @@ export async function getSingleArtwork(id: string) {
categories: true, categories: true,
colors: { include: { color: true } }, colors: { include: { color: true } },
// sortContexts: true, // sortContexts: true,
tagsV2: true, tags: 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) => ({ tagsV2: { _count: desc ? "desc" : "asc" } }), tagsCount: (desc) => ({ tags: { _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, tagsV2: true } }, _count: { select: { albums: true, categories: true, tags: 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.tagsV2, tagsCount: a._count.tags,
})); }));
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
? { ? {
tagsV2: { tags: {
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) => ({

View File

@ -1,102 +1,5 @@
"use server"; "use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function migrateArtTags() { export async function migrateArtTags() {
const artTags = await prisma.artTag.findMany({ throw new Error("Migration disabled: ArtTag models removed.");
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

@ -0,0 +1,75 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
type JoinMigrationResult = {
ok: boolean;
copied: number;
oldExists: boolean;
newExists: boolean;
droppedOld: boolean;
message?: string;
};
export async function migrateArtworkTagJoin(
opts: { dropOld?: boolean } = {},
): Promise<JoinMigrationResult> {
const dropOld = Boolean(opts.dropOld);
const [oldRow, newRow] = await Promise.all([
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTagsV2')::text as name;
`,
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTags')::text as name;
`,
]);
const oldExists = Boolean(oldRow?.[0]?.name);
const newExists = Boolean(newRow?.[0]?.name);
if (!newExists) {
return {
ok: false,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "New join table _ArtworkTags does not exist. Run the migration first.",
};
}
if (!oldExists) {
return {
ok: true,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "Old join table _ArtworkTagsV2 not found. Nothing to copy.",
};
}
const copied = await prisma.$executeRawUnsafe(`
INSERT INTO "_ArtworkTags" ("A","B")
SELECT "A","B" FROM "_ArtworkTagsV2"
ON CONFLICT ("A","B") DO NOTHING;
`);
let droppedOld = false;
if (dropOld) {
await prisma.$executeRawUnsafe(`DROP TABLE "_ArtworkTagsV2";`);
droppedOld = true;
}
revalidatePath("/tags");
return {
ok: true,
copied: Number(copied ?? 0),
oldExists,
newExists,
droppedOld,
};
}

View File

@ -1,9 +1,19 @@
import { migrateArtworkTagJoin } from "@/actions/tags/migrateArtworkTagJoin";
import TagTabs from "@/components/tags/TagTabs"; import TagTabs from "@/components/tags/TagTabs";
import { Button } from "@/components/ui/button"; 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";
async function migrateArtworkTagJoinCopy() {
"use server";
await migrateArtworkTagJoin();
}
async function migrateArtworkTagJoinDropOld() {
"use server";
await migrateArtworkTagJoin({ dropOld: true });
}
export default async function ArtTagsPage() { export default async function ArtTagsPage() {
const items = await prisma.tag.findMany({ const items = await prisma.tag.findMany({
@ -52,9 +62,14 @@ export default async function ArtTagsPage() {
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<form action={migrateArtTags}> <form action={migrateArtworkTagJoinCopy}>
<Button type="submit" variant="secondary" className="h-11"> <Button type="submit" variant="secondary" className="h-11">
Migrate old tags Copy tag relations
</Button>
</form>
<form action={migrateArtworkTagJoinDropOld}>
<Button type="submit" variant="destructive" className="h-11">
Copy + drop old
</Button> </Button>
</form> </form>
<Button asChild className="h-11 gap-2"> <Button asChild className="h-11 gap-2">

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.tagsV2?.length ?? 0)} tags</Badge> <Badge variant="secondary">{(artwork.tags?.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

@ -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.tagsV2?.map(tag => tag.id) ?? [], tagIds: artwork.tags?.map(tag => tag.id) ?? [],
newCategoryNames: [], newCategoryNames: [],
newTagNames: [] newTagNames: []
} }
@ -283,6 +283,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
.filter((t) => selectedTagIds.includes(t.id)) .filter((t) => selectedTagIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id })); .map((t) => ({ label: t.name, value: t.id }));
const fallbackSelectedOptions =
artwork.tags
?.filter((t) => selectedTagIds.includes(t.id))
.filter((t) => !selectedExistingOptions.some((o) => o.value === t.id))
.map((t) => ({ label: t.name, value: t.id })) ?? [];
// Selected "new" tags (so they remain visible) // Selected "new" tags (so they remain visible)
const selectedNewOptions = newTagNames.map((name) => ({ const selectedNewOptions = newTagNames.map((name) => ({
label: `Create: ${name}`, label: `Create: ${name}`,
@ -302,7 +308,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
placeholder="Select or type to create tags" placeholder="Select or type to create tags"
hidePlaceholderWhenSelected hidePlaceholderWhenSelected
selectFirstItem selectFirstItem
value={[...selectedExistingOptions, ...selectedNewOptions]} value={[
...selectedExistingOptions,
...fallbackSelectedOptions,
...selectedNewOptions,
]}
creatable creatable
createOption={(raw) => ({ createOption={(raw) => ({
value: `__new__:${raw}`, value: `__new__:${raw}`,

View File

@ -142,15 +142,6 @@ function removePickedOption(groupOption: GroupOption, picked: Option[]) {
return cloneOption; return cloneOption;
} }
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
function normalizeInput(s: string) { function normalizeInput(s: string) {
return s.trim().replace(/\s+/g, " "); return s.trim().replace(/\s+/g, " ");
} }
@ -238,7 +229,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
[selected], [selected],
); );
const handleClickOutside = (event: MouseEvent | TouchEvent) => { const handleClickOutside = React.useCallback((event: MouseEvent | TouchEvent) => {
if ( if (
dropdownRef.current && dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) && !dropdownRef.current.contains(event.target as Node) &&
@ -248,7 +239,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
setOpen(false); setOpen(false);
inputRef.current.blur(); inputRef.current.blur();
} }
}; }, []);
const handleUnselect = React.useCallback( const handleUnselect = React.useCallback(
(option: Option) => { (option: Option) => {
@ -294,7 +285,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside); document.removeEventListener('touchend', handleClickOutside);
}; };
}, [open]); }, [open, handleClickOutside]);
useEffect(() => { useEffect(() => {
if (value) { if (value) {
@ -311,7 +302,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
if (JSON.stringify(newOption) !== JSON.stringify(options)) { if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption); setOptions(newOption);
} }
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); }, [arrayOptions, groupBy, onSearch, options]);
useEffect(() => { useEffect(() => {
/** sync search */ /** sync search */
@ -334,8 +325,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
}; };
void exec(); void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, onSearchSync, open, triggerSearchOnFocus]);
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => { useEffect(() => {
/** async search */ /** async search */
@ -360,8 +350,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
}; };
void exec(); void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedSearchTerm, groupBy, onSearch, open, triggerSearchOnFocus]);
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => { const CreatableItem = () => {
if (!creatable) return undefined; if (!creatable) return undefined;
@ -448,14 +437,10 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return commandProps.filter; return commandProps.filter;
} }
if (creatable) { return (value: string, search: string) => {
return (value: string, search: string) => { return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; };
}; }, [commandProps?.filter]);
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
const orderedGroupEntries = React.useMemo(() => { const orderedGroupEntries = React.useMemo(() => {
const entries = Object.entries(selectables); const entries = Object.entries(selectables);
@ -495,7 +480,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
} // When onSearch is provided, we don't want to filter the options. You can still override it. } // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()} filter={commandFilter()}
> >
<div <button
type="button"
className={cn( className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm', 'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
{ {
@ -508,6 +494,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
if (disabled) return; if (disabled) return;
inputRef?.current?.focus(); inputRef?.current?.focus();
}} }}
disabled={disabled}
> >
<div className="relative flex flex-wrap gap-1"> <div className="relative flex flex-wrap gap-1">
{selected.map((option) => { {selected.map((option) => {
@ -594,7 +581,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
<X /> <X />
</button> </button>
</div> </div>
</div> </button>
<div className="relative"> <div className="relative">
{open && ( {open && (
<CommandList <CommandList
@ -626,7 +613,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return ( return (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.value} value={`${option.label}::${option.value}`}
disabled={disabledItem} disabled={disabledItem}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();

View File

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

View File

@ -8,7 +8,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
albums: true; albums: true;
categories: true; categories: true;
colors: true; colors: true;
tagsV2: true; tags: true;
variants: true; variants: true;
timelapse: true; timelapse: true;
}; };