Add tags and categories

This commit is contained in:
2025-12-20 17:37:52 +01:00
parent dfb6f7042a
commit e90578c98a
23 changed files with 913 additions and 45 deletions

View File

@ -0,0 +1,25 @@
"use server"
import { prisma } from "@/lib/prisma"
import { categorySchema } from "@/schemas/artworks/categorySchema"
export async function createCategory(formData: categorySchema) {
const parsed = categorySchema.safeParse(formData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const created = await prisma.artCategory.create({
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return created
}

View File

@ -0,0 +1,27 @@
"use server"
import { prisma } from '@/lib/prisma';
import { categorySchema } from '@/schemas/artworks/categorySchema';
import { z } from 'zod/v4';
export async function updateCategory(id: string, rawData: z.infer<typeof categorySchema>) {
const parsed = categorySchema.safeParse(rawData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const updated = await prisma.artCategory.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return updated
}

23
src/actions/deleteItem.ts Normal file
View File

@ -0,0 +1,23 @@
"use server";
import { prisma } from "@/lib/prisma";
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;
}
return { success: true };
}

View File

@ -0,0 +1,36 @@
"use server"
import { prisma } from "@/lib/prisma"
import { tagSchema } from "@/schemas/artworks/tagSchema"
export async function createTag(formData: tagSchema) {
const parsed = tagSchema.safeParse(formData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
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 },
data: {
categories: {
set: data.categoryIds.map(id => ({ id }))
}
}
});
}
return created
}

View File

@ -0,0 +1,38 @@
"use server"
import { prisma } from '@/lib/prisma';
import { tagSchema } from '@/schemas/artworks/tagSchema';
import { z } from 'zod/v4';
export async function updateTag(id: string, rawData: z.infer<typeof tagSchema>) {
const parsed = tagSchema.safeParse(rawData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const updated = await prisma.artTag.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
if (data.categoryIds) {
await prisma.artTag.update({
where: { id: id },
data: {
categories: {
set: data.categoryIds.map(id => ({ id }))
}
}
});
}
return updated
}

View File

@ -24,7 +24,7 @@ export default async function ArtworkSinglePage({ params }: { params: { id: stri
}
})
const categories = await prisma.artCategory.findMany({ orderBy: { sortIndex: "asc" } });
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } });
const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } });
if (!item) return <div>Artwork with this id not found</div>

View File

@ -0,0 +1,18 @@
import EditCategoryForm from "@/components/categories/EditCategoryForm";
import { prisma } from "@/lib/prisma";
export default async function PortfolioCategoriesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const category = await prisma.artCategory.findUnique({
where: {
id,
}
})
return (
<div>
<h1 className="text-2xl font-bold mb-4">Edit Category</h1>
{category && <EditCategoryForm category={category} />}
</div>
);
}

View File

@ -0,0 +1,10 @@
import NewCategoryForm from "@/components/categories/NewCategoryForm";
export default function PortfolioCategoriesNewPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">New Category</h1>
<NewCategoryForm />
</div>
);
}

View File

@ -0,0 +1,20 @@
import ItemList from "@/components/lists/ItemList";
import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function CategoriesPage() {
const items = await prisma.artCategory.findMany({})
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Categories</h1>
<Link href="/categories/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new category
</Link>
</div>
{items && items.length > 0 ? <ItemList items={items} type="categories" /> : <p>There are no categories yet. Consider adding some!</p>}
</div>
);
}

View File

@ -0,0 +1,23 @@
import EditTagForm from "@/components/tags/EditTagForm";
import { prisma } from "@/lib/prisma";
export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const tag = await prisma.artTag.findUnique({
where: {
id,
},
include: {
categories: true
}
})
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } });
return (
<div>
<h1 className="text-2xl font-bold mb-4">Edit Tag</h1>
{tag && <EditTagForm tag={tag} categories={categories} />}
</div>
);
}

13
src/app/tags/new/page.tsx Normal file
View File

@ -0,0 +1,13 @@
import NewTagForm from "@/components/tags/NewTagForm";
import { prisma } from "@/lib/prisma";
export default async function PortfolioTagsNewPage() {
const categories = await prisma.artCategory.findMany({ include: { tags: true }, orderBy: { sortIndex: "asc" } });
return (
<div>
<h1 className="text-2xl font-bold mb-4">New Tag</h1>
<NewTagForm categories={categories} />
</div>
);
}

20
src/app/tags/page.tsx Normal file
View File

@ -0,0 +1,20 @@
import ItemList from "@/components/lists/ItemList";
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({})
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Tags</h1>
<Link href="/tags/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<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>}
</div>
);
}

View File

@ -10,12 +10,12 @@ import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { ArtCategory, ArtTag } from "@/generated/prisma/client";
import { ArtTag } from "@/generated/prisma/client";
// import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioAlbum, PortfolioCategory, PortfolioImage, PortfolioSortContext, PortfolioTag, PortfolioType } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { artworkSchema } from "@/schemas/artworks/imageSchema";
// import { imageSchema } from "@/schemas/portfolio/imageSchema";
import { ArtworkWithRelations } from "@/types/Artwork";
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useRouter } from "next/navigation";
@ -26,7 +26,7 @@ import { z } from "zod/v4";
export default function EditArtworkForm({ artwork, categories, tags }:
{
artwork: ArtworkWithRelations,
categories: ArtCategory[]
categories: CategoryWithTags[]
tags: ArtTag[]
}) {
const router = useRouter();
@ -246,37 +246,6 @@ export default function EditArtworkForm({ artwork, categories, tags }:
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedOptions = tags
.filter(tag => field.value?.includes(tag.id))
.map(tag => ({ label: tag.name, value: tag.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={tags.map(tag => ({
label: tag.name,
value: tag.id,
}))}
placeholder="Select tags"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
name="categoryIds"
@ -308,6 +277,64 @@ export default function EditArtworkForm({ artwork, categories, tags }:
)
}}
/>
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedTagIds = field.value ?? [];
const selectedCategoryIds = form.watch("categoryIds") ?? [];
// Tag IDs connected to selected categories
const preferredTagIds = new Set<string>();
for (const cat of categories) {
if (!selectedCategoryIds.includes(cat.id)) continue;
for (const t of cat.tags) preferredTagIds.add(t.id);
}
// Build grouped options: Selected -> Category -> Other
const tagOptions = tags
.map((t) => {
let group = "Other tags";
if (selectedTagIds.includes(t.id)) group = "Selected";
else if (preferredTagIds.has(t.id)) group = "From selected categories";
return {
label: t.name,
value: t.id,
group, // IMPORTANT: groupBy will use this
};
})
// Optional: stable ordering within each group
.sort((a, b) => a.label.localeCompare(b.label));
// Selected value objects
const selectedOptions = tags
.filter((t) => selectedTagIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
options={tagOptions}
groupBy="group"
groupOrder={["Selected", "From selected categories", "Other tags"]}
showSelectedInDropdown
placeholder="Select tags"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => field.onChange(options.map((o) => o.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
{/* Boolean */}
<FormField
control={form.control}

View File

@ -0,0 +1,91 @@
"use client"
import { updateCategory } from "@/actions/categories/updateCategory";
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 { categorySchema } from "@/schemas/artworks/categorySchema";
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";
export default function EditCategoryForm({ category }: { category: ArtCategory }) {
const router = useRouter();
const form = useForm<z.infer<typeof categorySchema>>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: category.name,
slug: category.slug,
description: category.description || "",
}
})
async function onSubmit(values: z.infer<typeof categorySchema>) {
try {
const updated = await updateCategory(category.id, values)
console.log("Art category updated:", updated)
toast("Art category updated.")
router.push("/portfolio/categories")
} catch (err) {
console.error(err)
toast("Failed to update art category.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</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"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</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>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,92 @@
"use client"
import { createCategory } from "@/actions/categories/createCategory";
// import { createCategory } from "@/actions/portfolio/categories/createCategory";
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 { categorySchema } from "@/schemas/artworks/categorySchema";
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";
export default function NewCategoryForm() {
const router = useRouter();
const form = useForm<z.infer<typeof categorySchema>>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: "",
slug: "",
description: "",
}
})
async function onSubmit(values: z.infer<typeof categorySchema>) {
try {
const created = await createCategory(values)
console.log("Art category created:", created)
toast("Art category created.")
router.push("/categories")
} catch (err) {
console.error(err)
toast("Failed to create art category.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</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"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</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>
</div>
</form>
</Form>
</div >
);
}

View File

@ -14,6 +14,17 @@ const uploadItems = [
},
]
const artworkItems = [
{
title: "Categories",
href: "/categories",
},
{
title: "Tags",
href: "/tags",
},
]
// const portfolioItems = [
// {
// title: "Images",
@ -73,6 +84,25 @@ export default function TopNav() {
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Artwork Management</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-50 gap-4">
{artworkItems.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>
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
<NavigationMenuContent>

View File

@ -0,0 +1,75 @@
"use client";
import { deleteItems } from "@/actions/deleteItem";
import { PencilIcon } from "lucide-react";
import Link from "next/link";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../ui/card";
type ItemProps = {
id: string
name: string
slug: string
description: string | null
sortIndex: number
}
export default function ItemList({ items, type }: { items: ItemProps[], type: string }) {
// const [isMounted, setIsMounted] = useState(false);
// useEffect(() => {
// setIsMounted(true);
// }, []);
const handleDelete = (id: string) => {
deleteItems(id, type);
};
// if (!isMounted) return null;
return (
<div className="space-y-4">
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{items.map(item => (
<div key={item.id}>
<Card>
<CardHeader>
<CardTitle className="text-xl truncate">{item.name}</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
{/* {item.type === 'image' && (
<Image
src={`/api/image/thumbnail/${item.fileKey}.webp`}
alt={item.altText || item.name}
width={200}
height={200}
className="w-full h-auto object-cover"
/>
)} */}
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Link
href={`/${type}/${item.id}`}
className="w-full"
>
<Button variant="default" className="w-full flex items-center gap-2">
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="destructive"
className="w-full"
onClick={() => handleDelete(item.id)}
>
Delete
</Button>
</CardFooter>
</Card>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,124 @@
"use client"
import { updateTag } from "@/actions/tags/updateTag";
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 { 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";
export default function EditTagForm({ tag, categories }: { tag: ArtTag & { categories: ArtCategory[] }, categories: ArtCategory[] }) {
const router = useRouter();
const form = useForm<z.infer<typeof tagSchema>>({
resolver: zodResolver(tagSchema),
defaultValues: {
name: tag.name,
slug: tag.slug,
description: tag.description || "",
categoryIds: tag.categories?.map(cat => cat.id) ?? [],
}
})
async function onSubmit(values: z.infer<typeof tagSchema>) {
try {
const updated = await updateTag(tag.id, values)
console.log("Art tag updated:", updated)
toast("Art tag updated.")
router.push("/tags")
} catch (err) {
console.error(err)
toast("Failed to update art tag.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</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"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryIds"
render={({ field }) => {
const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id }));
return (
<FormItem>
<FormLabel>Categories</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={categories.map(cat => ({
label: cat.name,
value: cat.id,
}))}
placeholder="Select categories"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</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>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,125 @@
"use client"
import { createTag } from "@/actions/tags/createTag";
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 { 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";
export default function NewTagForm({ categories }: { categories: ArtCategory[] }) {
const router = useRouter();
const form = useForm<z.infer<typeof tagSchema>>({
resolver: zodResolver(tagSchema),
defaultValues: {
name: "",
slug: "",
description: "",
categoryIds: [],
}
})
async function onSubmit(values: z.infer<typeof tagSchema>) {
try {
const created = await createTag(values)
console.log("Art tag created:", created)
toast("Art tag created.")
router.push("/tags")
} catch (err) {
console.error(err)
toast("Failed to create art tag.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</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"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryIds"
render={({ field }) => {
const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id }));
return (
<FormItem>
<FormLabel>Categories</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={categories.map(cat => ({
label: cat.name,
value: cat.id,
}))}
placeholder="Select categories"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</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>
</div>
</form>
</Form>
</div >
);
}

View File

@ -77,6 +77,12 @@ interface MultipleSelectorProps {
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** Show selected items inside dropdown as disabled items (useful for "Selected" section). */
showSelectedInDropdown?: boolean;
/** Optional explicit group ordering (top to bottom). */
groupOrder?: string[];
}
export interface MultipleSelectorRef {
@ -192,6 +198,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
commandProps,
inputProps,
hideClearAllButton = false,
showSelectedInDropdown = false,
groupOrder,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
@ -404,10 +412,13 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
const selectables = React.useMemo<GroupOption>(() => {
if (showSelectedInDropdown) {
// keep all options; selected will be rendered disabled (see below)
return options;
}
return removePickedOption(options, selected);
}, [options, selected, showSelectedInDropdown]);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
@ -424,6 +435,30 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return undefined;
}, [creatable, commandProps?.filter]);
const orderedGroupEntries = React.useMemo(() => {
const entries = Object.entries(selectables);
if (!groupOrder || groupOrder.length === 0) {
// default: existing behavior
return entries;
}
const map = new Map(entries);
const ordered: Array<[string, Option[]]> = [];
for (const key of groupOrder) {
const v = map.get(key);
if (v) {
ordered.push([key, v]);
map.delete(key);
}
}
// any remaining groups not specified in groupOrder (alphabetical)
const rest = Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
return [...ordered, ...rest];
}, [selectables, groupOrder]);
return (
<Command
ref={dropdownRef}
@ -458,8 +493,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
<Badge
key={option.value}
className={cn(
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
'data-disabled:bg-muted-foreground data-disabled:text-muted data-disabled:hover:bg-muted-foreground',
'data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground',
badgeClassName,
)}
data-fixed={option.fixed}
@ -559,32 +594,42 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
{orderedGroupEntries.map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
const alreadySelected = selected.some((s) => s.value === option.value);
const disabledItem = option.disable || (showSelectedInDropdown && alreadySelected);
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disable}
disabled={disabledItem}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (disabledItem) return;
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
// Guard against duplicates (safety)
if (selected.some((s) => s.value === option.value)) return;
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable && 'cursor-default text-muted-foreground',
disabledItem && 'cursor-default text-muted-foreground',
)}
>
{option.label}

View File

@ -4,6 +4,7 @@ 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(),
})
export type tagSchema = z.infer<typeof tagSchema>

View File

@ -11,4 +11,8 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
tags: true;
variants: true;
};
}>;
export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{
include: { tags: true };
}>;

View File

@ -1,12 +1,13 @@
import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import "dotenv/config";
import { Readable } from "stream";
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
// const type = fileType ? fileType.split("/")[1] : "webp";
const command = new GetObjectCommand({
Bucket: "gaertan",
Bucket: `${process.env.BUCKET_NAME}`,
Key: `original/${fileKey}.${fileType}`,
});