Add tags and categories
This commit is contained in:
25
src/actions/categories/createCategory.ts
Normal file
25
src/actions/categories/createCategory.ts
Normal 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
|
||||
}
|
||||
27
src/actions/categories/updateCategory.ts
Normal file
27
src/actions/categories/updateCategory.ts
Normal 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
23
src/actions/deleteItem.ts
Normal 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 };
|
||||
}
|
||||
36
src/actions/tags/createTag.ts
Normal file
36
src/actions/tags/createTag.ts
Normal 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
|
||||
}
|
||||
38
src/actions/tags/updateTag.ts
Normal file
38
src/actions/tags/updateTag.ts
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
18
src/app/categories/[id]/page.tsx
Normal file
18
src/app/categories/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/categories/new/page.tsx
Normal file
10
src/app/categories/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/categories/page.tsx
Normal file
20
src/app/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/tags/[id]/page.tsx
Normal file
23
src/app/tags/[id]/page.tsx
Normal 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
13
src/app/tags/new/page.tsx
Normal 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
20
src/app/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
91
src/components/categories/EditCategoryForm.tsx
Normal file
91
src/components/categories/EditCategoryForm.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
92
src/components/categories/NewCategoryForm.tsx
Normal file
92
src/components/categories/NewCategoryForm.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
75
src/components/lists/ItemList.tsx
Normal file
75
src/components/lists/ItemList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/components/tags/EditTagForm.tsx
Normal file
124
src/components/tags/EditTagForm.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
125
src/components/tags/NewTagForm.tsx
Normal file
125
src/components/tags/NewTagForm.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -12,3 +12,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
|
||||
variants: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{
|
||||
include: { tags: true };
|
||||
}>;
|
||||
@ -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}`,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user