Enhance tags
This commit is contained in:
@ -1,23 +1,23 @@
|
||||
"use server";
|
||||
// "use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
// import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function deleteItems(itemId: string, type: string) {
|
||||
// export async function deleteItems(itemId: string, type: string) {
|
||||
|
||||
switch (type) {
|
||||
case "categories":
|
||||
await prisma.artCategory.delete({ where: { id: itemId } });
|
||||
break;
|
||||
case "tags":
|
||||
await prisma.artTag.delete({ where: { id: itemId } });
|
||||
break;
|
||||
// case "types":
|
||||
// await prisma.portfolioType.delete({ where: { id: itemId } });
|
||||
// break;
|
||||
// case "albums":
|
||||
// await prisma.portfolioAlbum.delete({ where: { id: itemId } });
|
||||
// break;
|
||||
}
|
||||
// switch (type) {
|
||||
// case "categories":
|
||||
// await prisma.artCategory.delete({ where: { id: itemId } });
|
||||
// break;
|
||||
// case "tags":
|
||||
// await prisma.artTag.delete({ where: { id: itemId } });
|
||||
// break;
|
||||
// // case "types":
|
||||
// // await prisma.portfolioType.delete({ where: { id: itemId } });
|
||||
// // break;
|
||||
// // case "albums":
|
||||
// // await prisma.portfolioAlbum.delete({ where: { id: itemId } });
|
||||
// // break;
|
||||
// }
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
// return { success: true };
|
||||
// }
|
||||
@ -1,9 +1,9 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { tagSchema } from "@/schemas/artworks/tagSchema"
|
||||
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"
|
||||
|
||||
export async function createTag(formData: tagSchema) {
|
||||
export async function createTag(formData: TagFormInput) {
|
||||
const parsed = tagSchema.safeParse(formData)
|
||||
|
||||
if (!parsed.success) {
|
||||
@ -12,25 +12,43 @@ export async function createTag(formData: tagSchema) {
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
|
||||
const parentId = data.parentId ?? null;
|
||||
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
const created = await prisma.artTag.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description
|
||||
},
|
||||
})
|
||||
|
||||
if (data.categoryIds) {
|
||||
await prisma.artTag.update({
|
||||
where: { id: created.id },
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const tag = await tx.artTag.create({
|
||||
data: {
|
||||
categories: {
|
||||
set: data.categoryIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
name: data.name,
|
||||
slug: tagSlug,
|
||||
description: data.description,
|
||||
parentId
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (data.categoryIds) {
|
||||
await tx.artTag.update({
|
||||
where: { id: tag.id },
|
||||
data: {
|
||||
categories: {
|
||||
set: data.categoryIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.aliases && data.aliases.length > 0) {
|
||||
await tx.artTagAlias.createMany({
|
||||
data: data.aliases.map((alias) => ({
|
||||
tagId: tag.id,
|
||||
alias,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return tag;
|
||||
});
|
||||
|
||||
return created
|
||||
}
|
||||
40
src/actions/tags/deleteTag.ts
Normal file
40
src/actions/tags/deleteTag.ts
Normal file
@ -0,0 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function deleteTag(tagId: string) {
|
||||
const tag = await prisma.artTag.findUnique({
|
||||
where: { id: tagId },
|
||||
select: {
|
||||
id: true,
|
||||
_count: {
|
||||
select: {
|
||||
artworks: true,
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
throw new Error("Tag not found.");
|
||||
}
|
||||
|
||||
if (tag._count.artworks > 0) {
|
||||
throw new Error("Cannot delete tag: it is used by artworks.");
|
||||
}
|
||||
|
||||
if (tag._count.children > 0) {
|
||||
throw new Error("Cannot delete tag: it has child tags.");
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.artTagAlias.deleteMany({ where: { tagId } });
|
||||
await tx.artTag.delete({ where: { id: tagId } });
|
||||
});
|
||||
|
||||
revalidatePath("/tags");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
17
src/actions/tags/isDescendant.ts
Normal file
17
src/actions/tags/isDescendant.ts
Normal file
@ -0,0 +1,17 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function isDescendant(tagId: string, possibleAncestorId: string) {
|
||||
// Walk upwards from possibleAncestorId; if we hit tagId, it's a cycle.
|
||||
let current: string | null = possibleAncestorId;
|
||||
while (current) {
|
||||
if (current === tagId) return true;
|
||||
const t = await prisma.artTag.findUnique({
|
||||
where: { id: current },
|
||||
select: { parentId: true },
|
||||
});
|
||||
current = t?.parentId ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { tagSchema } from '@/schemas/artworks/tagSchema';
|
||||
import { z } from 'zod/v4';
|
||||
import { TagFormInput, tagSchema } from '@/schemas/artworks/tagSchema';
|
||||
import { isDescendant } from './isDescendant';
|
||||
|
||||
export async function updateTag(id: string, rawData: z.infer<typeof tagSchema>) {
|
||||
export async function updateTag(id: string, rawData: TagFormInput) {
|
||||
const parsed = tagSchema.safeParse(rawData)
|
||||
|
||||
if (!parsed.success) {
|
||||
@ -14,25 +14,60 @@ export async function updateTag(id: string, rawData: z.infer<typeof tagSchema>)
|
||||
|
||||
const data = parsed.data
|
||||
|
||||
const updated = await prisma.artTag.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
description: data.description
|
||||
},
|
||||
})
|
||||
const parentId = data.parentId ?? null;
|
||||
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
|
||||
|
||||
if (data.categoryIds) {
|
||||
await prisma.artTag.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
categories: {
|
||||
set: data.categoryIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
});
|
||||
if (parentId === id) {
|
||||
throw new Error("A tag cannot be its own parent.");
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const cycle = await isDescendant(id, parentId);
|
||||
if (cycle) throw new Error("Invalid parent tag (would create a cycle).");
|
||||
}
|
||||
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const tag = await tx.artTag.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
slug: tagSlug,
|
||||
description: data.description,
|
||||
parentId,
|
||||
categories: data.categoryIds
|
||||
? { set: data.categoryIds.map((cid) => ({ id: cid })) }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await tx.artTagAlias.findMany({
|
||||
where: { tagId: id },
|
||||
select: { id: true, alias: true },
|
||||
});
|
||||
|
||||
const desired = new Set((data.aliases ?? []).map((a) => a));
|
||||
const existingSet = new Set(existing.map((a) => a.alias));
|
||||
|
||||
const toCreate = Array.from(desired).filter((a) => !existingSet.has(a));
|
||||
const toDeleteIds = existing
|
||||
.filter((a) => !desired.has(a.alias))
|
||||
.map((a) => a.id);
|
||||
|
||||
if (toDeleteIds.length > 0) {
|
||||
await tx.artTagAlias.deleteMany({
|
||||
where: { id: { in: toDeleteIds } },
|
||||
});
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await tx.artTagAlias.createMany({
|
||||
data: toCreate.map((alias) => ({ tagId: id, alias })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return tag;
|
||||
});
|
||||
|
||||
return updated
|
||||
}
|
||||
@ -8,16 +8,18 @@ export default async function PortfolioTagsEditPage({ params }: { params: { id:
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
categories: true
|
||||
categories: true,
|
||||
aliases: true
|
||||
}
|
||||
})
|
||||
|
||||
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } });
|
||||
const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit Tag</h1>
|
||||
{tag && <EditTagForm tag={tag} categories={categories} />}
|
||||
{tag && <EditTagForm tag={tag} categories={categories} allTags={tags} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,11 +3,12 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function PortfolioTagsNewPage() {
|
||||
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } });
|
||||
const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">New Tag</h1>
|
||||
<NewTagForm categories={categories} />
|
||||
<NewTagForm categories={categories} allTags={tags} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,18 @@
|
||||
import ItemList from "@/components/lists/ItemList";
|
||||
import TagTable from "@/components/tags/TagTable";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function PortfolioTagsPage() {
|
||||
const items = await prisma.artTag.findMany({})
|
||||
export default async function ArtTagsPage() {
|
||||
const items = await prisma.artTag.findMany({
|
||||
include: {
|
||||
parent: { select: { id: true, name: true } },
|
||||
aliases: { select: { alias: true } },
|
||||
categories: { select: { id: true, name: true } },
|
||||
_count: { select: { artworks: true } },
|
||||
},
|
||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -14,7 +22,11 @@ export default async function PortfolioTagsPage() {
|
||||
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new tag
|
||||
</Link>
|
||||
</div>
|
||||
{items && items.length > 0 ? <ItemList items={items} type="tags" /> : <p>There are no tags yet. Consider adding some!</p>}
|
||||
{items.length > 0 ? (
|
||||
<TagTable tags={items} />
|
||||
) : (
|
||||
<p>There are no tags yet. Consider adding some!</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/tags/AliasEditor.tsx
Normal file
71
src/components/tags/AliasEditor.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
export default function AliasEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const add = () => {
|
||||
const v = draft.trim();
|
||||
if (!v) return;
|
||||
if (value.includes(v)) {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
onChange([...value, v]);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const remove = (alias: string) => {
|
||||
onChange(value.filter((a) => a !== alias));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder='Add alias, e.g. "anthro"'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={add}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">No aliases.</span>
|
||||
) : (
|
||||
value.map((a) => (
|
||||
<span key={a} className="inline-flex items-center gap-2 rounded bg-muted px-2 py-1 text-sm">
|
||||
<span className="font-mono">{a}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => remove(a)}
|
||||
aria-label={`Remove alias ${a}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,28 +5,30 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtCategory, ArtTag } from "@/generated/prisma/client";
|
||||
import { tagSchema } from "@/schemas/artworks/tagSchema";
|
||||
import { ArtCategory, ArtTag, ArtTagAlias } from "@/generated/prisma/client";
|
||||
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod/v4";
|
||||
import MultipleSelector from "../ui/multiselect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
import AliasEditor from "./AliasEditor";
|
||||
|
||||
export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categories: ArtCategory[] }, categories: ArtCategory[] }) {
|
||||
export default function EditTagForm({ tag, categories, allTags }: { tag: ArtTag & { categories: ArtCategory[], aliases: ArtTagAlias[] }, categories: ArtCategory[], allTags: ArtTag[] }) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof tagSchema>>({
|
||||
const form = useForm<TagFormInput>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: {
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || "",
|
||||
categoryIds: tag.categories?.map(cat => cat.id) ?? [],
|
||||
parentId: (tag as any).parentId ?? null,
|
||||
aliases: tag.aliases?.map(a => a.alias) ?? []
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof tagSchema>) {
|
||||
async function onSubmit(values: TagFormInput) {
|
||||
try {
|
||||
const updated = await updateTag(tag.id, values)
|
||||
console.log("Art tag updated:", updated)
|
||||
@ -38,6 +40,10 @@ export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categ
|
||||
}
|
||||
}
|
||||
|
||||
const parentOptions = allTags
|
||||
.filter((t) => t.id !== tag.id) // exclude self
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Form {...form}>
|
||||
@ -56,19 +62,6 @@ export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categ
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="The slug shown in the navigation" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
@ -113,6 +106,47 @@ export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categ
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Parent tag</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? "none"}
|
||||
onValueChange={(v) => field.onChange(v === "none" ? null : v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No parent (top-level tag)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No parent</SelectItem>
|
||||
{parentOptions.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aliases"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases</FormLabel>
|
||||
<FormControl>
|
||||
<AliasEditor value={field.value ?? []} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
|
||||
@ -5,29 +5,31 @@ import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtCategory } from "@/generated/prisma/client";
|
||||
import { tagSchema } from "@/schemas/artworks/tagSchema";
|
||||
import { ArtCategory, ArtTag } from "@/generated/prisma/client";
|
||||
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod/v4";
|
||||
import MultipleSelector from "../ui/multiselect";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
import AliasEditor from "./AliasEditor";
|
||||
|
||||
|
||||
export default function NewTagForm({ categories }: { categories: ArtCategory[] }) {
|
||||
export default function NewTagForm({ categories, allTags }: { categories: ArtCategory[], allTags: ArtTag[] }) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof tagSchema>>({
|
||||
const form = useForm<TagFormInput>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
categoryIds: [],
|
||||
parentId: null,
|
||||
aliases: [],
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof tagSchema>) {
|
||||
async function onSubmit(values: TagFormInput) {
|
||||
try {
|
||||
const created = await createTag(values)
|
||||
console.log("Art tag created:", created)
|
||||
@ -39,6 +41,10 @@ export default function NewTagForm({ categories }: { categories: ArtCategory[] }
|
||||
}
|
||||
}
|
||||
|
||||
const parentOptions = allTags
|
||||
// .filter((t) => t.id !== tag.id) // exclude self
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Form {...form}>
|
||||
@ -57,19 +63,6 @@ export default function NewTagForm({ categories }: { categories: ArtCategory[] }
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="The slug shown in the navigation" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
@ -114,6 +107,47 @@ export default function NewTagForm({ categories }: { categories: ArtCategory[] }
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parentId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Parent tag</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? "none"}
|
||||
onValueChange={(v) => field.onChange(v === "none" ? null : v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No parent (top-level tag)" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No parent</SelectItem>
|
||||
{parentOptions.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="aliases"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases</FormLabel>
|
||||
<FormControl>
|
||||
<AliasEditor value={field.value ?? []} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
|
||||
136
src/components/tags/TagTable.tsx
Normal file
136
src/components/tags/TagTable.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { deleteTag } from "@/actions/tags/deleteTag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { PencilIcon, Trash2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
type TagRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent: { id: string; name: string } | null;
|
||||
aliases: { alias: string }[];
|
||||
categories: { id: string; name: string }[];
|
||||
_count: { artworks: number };
|
||||
};
|
||||
|
||||
function Chips({
|
||||
values,
|
||||
empty = "—",
|
||||
max = 4,
|
||||
mono = false,
|
||||
}: {
|
||||
values: string[];
|
||||
empty?: string;
|
||||
max?: number;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
if (values.length === 0) return <span className="text-muted-foreground">{empty}</span>;
|
||||
|
||||
const shown = values.slice(0, max);
|
||||
const extra = values.length - shown.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shown.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className={[
|
||||
"rounded bg-muted px-2 py-1 text-xs",
|
||||
mono ? "font-mono" : "",
|
||||
].join(" ")}
|
||||
title={v}
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
{extra > 0 ? <span className="text-xs text-muted-foreground">+{extra} more</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagTable({ tags }: { tags: TagRow[] }) {
|
||||
const handleDelete = (id: string) => {
|
||||
deleteTag(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[16%]">Name</TableHead>
|
||||
<TableHead className="w-[12%]">Slug</TableHead>
|
||||
<TableHead className="w-[18%]">Aliases</TableHead>
|
||||
<TableHead className="w-[22%]">Categories</TableHead>
|
||||
<TableHead className="w-[14%]">Parent</TableHead>
|
||||
<TableHead className="w-[8%] text-right">Artworks</TableHead>
|
||||
<TableHead className="w-[10%] text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{tags.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
|
||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||
#{t.slug}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Chips values={t.aliases.map((a) => a.alias)} mono max={5} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Chips values={t.categories.map((c) => c.name)} max={4} />
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{t.parent ? (
|
||||
<Link href={`/tags/${t.parent.id}`} className="underline underline-offset-2">
|
||||
{t.parent.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t._count.artworks}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/tags/${t.id}`} aria-label={`Edit ${t.name}`}>
|
||||
<Button size="icon" variant="secondary">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
aria-label={`Delete ${t.name}`}
|
||||
onClick={() => handleDelete(t.id)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@ -44,7 +44,7 @@ export default function UploadImageForm() {
|
||||
const image = await createImage(values)
|
||||
if (image) {
|
||||
toast.success("Image created")
|
||||
router.push(`/portfolio/images/${image.id}`)
|
||||
router.push(`/artworks/${image.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ export type ArtworkListParams = {
|
||||
};
|
||||
|
||||
export async function getArtworksPage(params: ArtworkListParams) {
|
||||
const { published = "all", take = 48, cursor } = params;
|
||||
const { published = "all", cursor, take = 48 } = params;
|
||||
|
||||
const where: Prisma.ArtworkWhereInput = {};
|
||||
|
||||
@ -21,8 +21,13 @@ export async function getArtworksPage(params: ArtworkListParams) {
|
||||
where,
|
||||
include: {
|
||||
file: true,
|
||||
variants: true,
|
||||
gallery: true,
|
||||
metadata: true,
|
||||
albums: true,
|
||||
categories: true,
|
||||
colors: true,
|
||||
tags: true,
|
||||
variants: true,
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
|
||||
take: take + 1, // fetch one extra to know if there is a next page
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import { z } from "zod/v4"
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const tagSchema = z.object({
|
||||
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||
description: z.string().optional(),
|
||||
categoryIds: z.array(z.string()).optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
|
||||
aliases: z
|
||||
.array(z.string().trim().min(1))
|
||||
.default([])
|
||||
.transform((arr) => {
|
||||
const normalized = arr.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
}),
|
||||
})
|
||||
|
||||
export type tagSchema = z.infer<typeof tagSchema>
|
||||
export type TagFormInput = z.input<typeof tagSchema>;
|
||||
export type TagFormOutput = z.output<typeof tagSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user