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" } });
|
const tags = await prisma.artTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
|
||||||
if (!item) return <div>Artwork with this id not found</div>
|
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { 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 { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioAlbum, PortfolioCategory, PortfolioImage, PortfolioSortContext, PortfolioTag, PortfolioType } from "@/generated/prisma";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
// import { imageSchema } from "@/schemas/portfolio/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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@ -26,7 +26,7 @@ import { z } from "zod/v4";
|
|||||||
export default function EditArtworkForm({ artwork, categories, tags }:
|
export default function EditArtworkForm({ artwork, categories, tags }:
|
||||||
{
|
{
|
||||||
artwork: ArtworkWithRelations,
|
artwork: ArtworkWithRelations,
|
||||||
categories: ArtCategory[]
|
categories: CategoryWithTags[]
|
||||||
tags: ArtTag[]
|
tags: ArtTag[]
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -246,37 +246,6 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="categoryIds"
|
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 */}
|
{/* Boolean */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
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 = [
|
// const portfolioItems = [
|
||||||
// {
|
// {
|
||||||
// title: "Images",
|
// title: "Images",
|
||||||
@ -73,6 +84,25 @@ export default function TopNav() {
|
|||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</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>
|
{/* <NavigationMenuItem>
|
||||||
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<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. */
|
/** hide the clear all button. */
|
||||||
hideClearAllButton?: boolean;
|
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 {
|
export interface MultipleSelectorRef {
|
||||||
@ -192,6 +198,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
commandProps,
|
commandProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
hideClearAllButton = false,
|
hideClearAllButton = false,
|
||||||
|
showSelectedInDropdown = false,
|
||||||
|
groupOrder,
|
||||||
}: MultipleSelectorProps,
|
}: MultipleSelectorProps,
|
||||||
ref: React.Ref<MultipleSelectorRef>,
|
ref: React.Ref<MultipleSelectorRef>,
|
||||||
) => {
|
) => {
|
||||||
@ -404,10 +412,13 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||||
}, [creatable, emptyIndicator, onSearch, options]);
|
}, [creatable, emptyIndicator, onSearch, options]);
|
||||||
|
|
||||||
const selectables = React.useMemo<GroupOption>(
|
const selectables = React.useMemo<GroupOption>(() => {
|
||||||
() => removePickedOption(options, selected),
|
if (showSelectedInDropdown) {
|
||||||
[options, selected],
|
// 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. */
|
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||||
const commandFilter = React.useCallback(() => {
|
const commandFilter = React.useCallback(() => {
|
||||||
@ -424,6 +435,30 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [creatable, commandProps?.filter]);
|
}, [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 (
|
return (
|
||||||
<Command
|
<Command
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
@ -458,8 +493,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
<Badge
|
<Badge
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]: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',
|
'data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground',
|
||||||
badgeClassName,
|
badgeClassName,
|
||||||
)}
|
)}
|
||||||
data-fixed={option.fixed}
|
data-fixed={option.fixed}
|
||||||
@ -559,32 +594,42 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
{EmptyItem()}
|
{EmptyItem()}
|
||||||
{CreatableItem()}
|
{CreatableItem()}
|
||||||
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
|
{!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">
|
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
|
||||||
<>
|
<>
|
||||||
{dropdowns.map((option) => {
|
{dropdowns.map((option) => {
|
||||||
|
const alreadySelected = selected.some((s) => s.value === option.value);
|
||||||
|
const disabledItem = option.disable || (showSelectedInDropdown && alreadySelected);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label}
|
value={option.label}
|
||||||
disabled={option.disable}
|
disabled={disabledItem}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
if (disabledItem) return;
|
||||||
|
|
||||||
if (selected.length >= maxSelected) {
|
if (selected.length >= maxSelected) {
|
||||||
onMaxSelected?.(selected.length);
|
onMaxSelected?.(selected.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
|
|
||||||
|
// Guard against duplicates (safety)
|
||||||
|
if (selected.some((s) => s.value === option.value)) return;
|
||||||
|
|
||||||
const newOptions = [...selected, option];
|
const newOptions = [...selected, option];
|
||||||
setSelected(newOptions);
|
setSelected(newOptions);
|
||||||
onChange?.(newOptions);
|
onChange?.(newOptions);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer',
|
'cursor-pointer',
|
||||||
option.disable && 'cursor-default text-muted-foreground',
|
disabledItem && 'cursor-default text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const tagSchema = z.object({
|
|||||||
name: z.string().min(3, "Name is required. Min 3 characters."),
|
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)"),
|
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(),
|
description: z.string().optional(),
|
||||||
|
categoryIds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type tagSchema = z.infer<typeof tagSchema>
|
export type tagSchema = z.infer<typeof tagSchema>
|
||||||
|
|||||||
@ -12,3 +12,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
|
|||||||
variants: true;
|
variants: true;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type CategoryWithTags = Prisma.ArtCategoryGetPayload<{
|
||||||
|
include: { tags: true };
|
||||||
|
}>;
|
||||||
@ -1,12 +1,13 @@
|
|||||||
import { s3 } from "@/lib/s3";
|
import { s3 } from "@/lib/s3";
|
||||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import "dotenv/config";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
|
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
|
||||||
// const type = fileType ? fileType.split("/")[1] : "webp";
|
// const type = fileType ? fileType.split("/")[1] : "webp";
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: "gaertan",
|
Bucket: `${process.env.BUCKET_NAME}`,
|
||||||
Key: `original/${fileKey}.${fileType}`,
|
Key: `original/${fileKey}.${fileType}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user