From a8d5dbaa093582b2a38caa0a320a8d53bf580ef1 Mon Sep 17 00:00:00 2001 From: Citali Date: Mon, 21 Jul 2025 23:45:39 +0200 Subject: [PATCH] Refactor portfolio --- .../portfolio/categories/createCategory.ts | 25 +++++ .../portfolio/categories/updateCategory.ts | 27 ++++++ src/actions/portfolio/deleteItem.ts | 20 ++++ src/actions/portfolio/images/updateImage.ts | 2 + src/actions/portfolio/sortItems.ts | 40 ++++++++ src/actions/portfolio/tags/createTag.ts | 25 +++++ src/actions/portfolio/tags/updateTag.ts | 27 ++++++ src/actions/portfolio/types/createType.ts | 25 +++++ src/actions/portfolio/types/updateType.ts | 27 ++++++ src/app/portfolio/categories/[id]/page.tsx | 17 +++- src/app/portfolio/categories/new/page.tsx | 7 +- src/app/portfolio/categories/page.tsx | 19 +++- src/app/portfolio/images/page.tsx | 39 +++++++- src/app/portfolio/tags/[id]/page.tsx | 17 +++- src/app/portfolio/tags/new/page.tsx | 7 +- src/app/portfolio/tags/page.tsx | 19 +++- src/app/portfolio/types/[id]/page.tsx | 17 +++- src/app/portfolio/types/new/page.tsx | 7 +- src/app/portfolio/types/page.tsx | 19 +++- src/components/portfolio/ItemList.tsx | 67 +++++++++++++ .../portfolio/categories/EditCategoryForm.tsx | 91 +++++++++++++++++ .../portfolio/categories/NewCategoryForm.tsx | 91 +++++++++++++++++ .../portfolio/images/EditImageForm.tsx | 8 +- src/components/portfolio/images/FilterBar.tsx | 97 +++++++++++++++++++ src/components/portfolio/images/ImageList.tsx | 6 ++ src/components/portfolio/tags/EditTagForm.tsx | 91 +++++++++++++++++ src/components/portfolio/tags/NewTagForm.tsx | 91 +++++++++++++++++ .../portfolio/types/EditTypeForm.tsx | 91 +++++++++++++++++ .../portfolio/types/NewTypeForm.tsx | 91 +++++++++++++++++ src/components/sort/items/SortableItem.tsx | 27 +++++- src/utils/getImageBufferFromS3.ts | 4 +- 31 files changed, 1111 insertions(+), 30 deletions(-) create mode 100644 src/actions/portfolio/categories/createCategory.ts create mode 100644 src/actions/portfolio/categories/updateCategory.ts create mode 100644 src/actions/portfolio/deleteItem.ts create mode 100644 src/actions/portfolio/sortItems.ts create mode 100644 src/actions/portfolio/tags/createTag.ts create mode 100644 src/actions/portfolio/tags/updateTag.ts create mode 100644 src/actions/portfolio/types/createType.ts create mode 100644 src/actions/portfolio/types/updateType.ts create mode 100644 src/components/portfolio/ItemList.tsx create mode 100644 src/components/portfolio/categories/EditCategoryForm.tsx create mode 100644 src/components/portfolio/categories/NewCategoryForm.tsx create mode 100644 src/components/portfolio/images/FilterBar.tsx create mode 100644 src/components/portfolio/tags/EditTagForm.tsx create mode 100644 src/components/portfolio/tags/NewTagForm.tsx create mode 100644 src/components/portfolio/types/EditTypeForm.tsx create mode 100644 src/components/portfolio/types/NewTypeForm.tsx diff --git a/src/actions/portfolio/categories/createCategory.ts b/src/actions/portfolio/categories/createCategory.ts new file mode 100644 index 0000000..f7d8e50 --- /dev/null +++ b/src/actions/portfolio/categories/createCategory.ts @@ -0,0 +1,25 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { categorySchema } from '@/schemas/portfolio/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.portfolioCategory.create({ + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return created +} \ No newline at end of file diff --git a/src/actions/portfolio/categories/updateCategory.ts b/src/actions/portfolio/categories/updateCategory.ts new file mode 100644 index 0000000..3d85935 --- /dev/null +++ b/src/actions/portfolio/categories/updateCategory.ts @@ -0,0 +1,27 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { categorySchema } from '@/schemas/portfolio/categorySchema'; +import { z } from 'zod/v4'; + +export async function updateCategory(id: string, rawData: z.infer) { + 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.portfolioCategory.update({ + where: { id }, + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return updated +} \ No newline at end of file diff --git a/src/actions/portfolio/deleteItem.ts b/src/actions/portfolio/deleteItem.ts new file mode 100644 index 0000000..0d5d297 --- /dev/null +++ b/src/actions/portfolio/deleteItem.ts @@ -0,0 +1,20 @@ +"use server"; + +import prisma from "@/lib/prisma"; + +export async function deleteItems(itemId: string, type: string) { + + switch (type) { + case "categories": + await prisma.portfolioCategory.delete({ where: { id: itemId } }); + break; + case "tags": + await prisma.portfolioTag.delete({ where: { id: itemId } }); + break; + case "types": + await prisma.portfolioType.delete({ where: { id: itemId } }); + break; + } + + return { success: true }; +} \ No newline at end of file diff --git a/src/actions/portfolio/images/updateImage.ts b/src/actions/portfolio/images/updateImage.ts index 50f92bc..1af6dca 100644 --- a/src/actions/portfolio/images/updateImage.ts +++ b/src/actions/portfolio/images/updateImage.ts @@ -24,6 +24,7 @@ export async function updateImage( name, fileSize, creationDate, + typeId, tagIds, categoryIds } = validated.data; @@ -41,6 +42,7 @@ export async function updateImage( name, fileSize, creationDate, + typeId } }); diff --git a/src/actions/portfolio/sortItems.ts b/src/actions/portfolio/sortItems.ts new file mode 100644 index 0000000..0292844 --- /dev/null +++ b/src/actions/portfolio/sortItems.ts @@ -0,0 +1,40 @@ +'use server'; + +import prisma from "@/lib/prisma"; +import { SortableItem } from "@/types/SortableItem"; + +export async function sortItems(items: SortableItem[], type: string) { + + switch(type) { + case "categories": + await Promise.all( + items.map(item => + prisma.portfolioCategory.update({ + where: { id: item.id }, + data: { sortIndex: item.sortIndex }, + }) + ) + ); + break; + case "tags": + await Promise.all( + items.map(item => + prisma.portfolioTag.update({ + where: { id: item.id }, + data: { sortIndex: item.sortIndex }, + }) + ) + ); + break; + case "types": + await Promise.all( + items.map(item => + prisma.portfolioType.update({ + where: { id: item.id }, + data: { sortIndex: item.sortIndex }, + }) + ) + ); + break; + } +} diff --git a/src/actions/portfolio/tags/createTag.ts b/src/actions/portfolio/tags/createTag.ts new file mode 100644 index 0000000..50893fe --- /dev/null +++ b/src/actions/portfolio/tags/createTag.ts @@ -0,0 +1,25 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { tagSchema } from '@/schemas/portfolio/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.portfolioTag.create({ + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return created +} \ No newline at end of file diff --git a/src/actions/portfolio/tags/updateTag.ts b/src/actions/portfolio/tags/updateTag.ts new file mode 100644 index 0000000..6f609c9 --- /dev/null +++ b/src/actions/portfolio/tags/updateTag.ts @@ -0,0 +1,27 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { tagSchema } from '@/schemas/portfolio/tagSchema'; +import { z } from 'zod/v4'; + +export async function updateTag(id: string, rawData: z.infer) { + 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.portfolioTag.update({ + where: { id }, + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return updated +} \ No newline at end of file diff --git a/src/actions/portfolio/types/createType.ts b/src/actions/portfolio/types/createType.ts new file mode 100644 index 0000000..e9fd9d9 --- /dev/null +++ b/src/actions/portfolio/types/createType.ts @@ -0,0 +1,25 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { typeSchema } from '@/schemas/portfolio/typeSchema'; + +export async function createType(formData: typeSchema) { + const parsed = typeSchema.safeParse(formData) + + if (!parsed.success) { + console.error("Validation failed", parsed.error) + throw new Error("Invalid input") + } + + const data = parsed.data + + const created = await prisma.portfolioType.create({ + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return created +} \ No newline at end of file diff --git a/src/actions/portfolio/types/updateType.ts b/src/actions/portfolio/types/updateType.ts new file mode 100644 index 0000000..ed6f438 --- /dev/null +++ b/src/actions/portfolio/types/updateType.ts @@ -0,0 +1,27 @@ +"use server" + +import prisma from '@/lib/prisma'; +import { typeSchema } from '@/schemas/portfolio/typeSchema'; +import { z } from 'zod/v4'; + +export async function updateType(id: string, rawData: z.infer) { + const parsed = typeSchema.safeParse(rawData) + + if (!parsed.success) { + console.error("Validation failed", parsed.error) + throw new Error("Invalid input") + } + + const data = parsed.data + + const updated = await prisma.portfolioType.update({ + where: { id }, + data: { + name: data.name, + slug: data.slug, + description: data.description + }, + }) + + return updated +} \ No newline at end of file diff --git a/src/app/portfolio/categories/[id]/page.tsx b/src/app/portfolio/categories/[id]/page.tsx index 86195c2..2542c82 100644 --- a/src/app/portfolio/categories/[id]/page.tsx +++ b/src/app/portfolio/categories/[id]/page.tsx @@ -1,5 +1,18 @@ -export default function PortfolioCategoriesEditPage() { +import EditCategoryForm from "@/components/portfolio/categories/EditCategoryForm"; +import prisma from "@/lib/prisma"; + +export default async function PortfolioCategoriesEditPage({ params }: { params: { id: string } }) { + const { id } = await params; + const category = await prisma.portfolioCategory.findUnique({ + where: { + id, + } + }) + return ( -
PortfolioCategoriesEditPage
+
+

Edit Category

+ {category && } +
); } \ No newline at end of file diff --git a/src/app/portfolio/categories/new/page.tsx b/src/app/portfolio/categories/new/page.tsx index f5d8e51..b336cb7 100644 --- a/src/app/portfolio/categories/new/page.tsx +++ b/src/app/portfolio/categories/new/page.tsx @@ -1,5 +1,10 @@ +import NewCategoryForm from "@/components/portfolio/categories/NewCategoryForm"; + export default function PortfolioCategoriesNewPage() { return ( -
PortfolioCategoriesNewPage
+
+

New Category

+ +
); } \ No newline at end of file diff --git a/src/app/portfolio/categories/page.tsx b/src/app/portfolio/categories/page.tsx index c355c7b..2db7813 100644 --- a/src/app/portfolio/categories/page.tsx +++ b/src/app/portfolio/categories/page.tsx @@ -1,5 +1,20 @@ -export default function PortfolioCategoriesPage() { +import ItemList from "@/components/portfolio/ItemList"; +import prisma from "@/lib/prisma"; +import { PlusCircleIcon } from "lucide-react"; +import Link from "next/link"; + +export default async function PortfolioCategoriesPage() { + const items = await prisma.portfolioCategory.findMany({}) + return ( -
PortfolioCategoriesPage
+
+
+

Art Categories

+ + Add new category + +
+ {items && items.length > 0 ? :

There are no categories yet. Consider adding some!

} +
); } \ No newline at end of file diff --git a/src/app/portfolio/images/page.tsx b/src/app/portfolio/images/page.tsx index 24ac32b..70aec72 100644 --- a/src/app/portfolio/images/page.tsx +++ b/src/app/portfolio/images/page.tsx @@ -1,24 +1,55 @@ +import FilterBar from "@/components/portfolio/images/FilterBar"; import ImageList from "@/components/portfolio/images/ImageList"; +import { Prisma } from "@/generated/prisma"; import prisma from "@/lib/prisma"; import { PlusCircleIcon } from "lucide-react"; import Link from "next/link"; -export default async function PortfolioImagesPage() { +export default async function PortfolioImagesPage( + { searchParams }: + { searchParams: { type: string, published: string } } +) { + const { type, published } = await searchParams; + + const types = await prisma.portfolioType.findMany({ + orderBy: { sortIndex: "asc" }, + }); + + const typeFilter = type ?? "all"; + const publishedFilter = published ?? "all"; + + const where: Prisma.PortfolioImageWhereInput = {}; + + if (typeFilter !== "all") { + where.typeId = typeFilter === "none" ? null : typeFilter; + } + + if (publishedFilter === "published") { + where.published = true; + } else if (publishedFilter === "unpublished") { + where.published = false; + } + const images = await prisma.portfolioImage.findMany( { - orderBy: [{ sortIndex: 'asc' }] + where, + orderBy: [{ sortIndex: 'asc' }], } ) return (
-
+

Images

Upload new image
- {images && images.length > 0 ? :

There are no images yet. Consider adding some!

} + + +
+ {images && images.length > 0 ? :

There are no images yet. Consider adding some!

} +
); } \ No newline at end of file diff --git a/src/app/portfolio/tags/[id]/page.tsx b/src/app/portfolio/tags/[id]/page.tsx index da136b3..fde8eea 100644 --- a/src/app/portfolio/tags/[id]/page.tsx +++ b/src/app/portfolio/tags/[id]/page.tsx @@ -1,5 +1,18 @@ -export default function PortfolioTagsEditPage() { +import EditTagForm from "@/components/portfolio/tags/EditTagForm"; +import prisma from "@/lib/prisma"; + +export default async function PortfolioTagsEditPage({ params }: { params: { id: string } }) { + const { id } = await params; + const tag = await prisma.portfolioTag.findUnique({ + where: { + id, + } + }) + return ( -
PortfolioTagsEditPage
+
+

Edit Tag

+ {tag && } +
); } \ No newline at end of file diff --git a/src/app/portfolio/tags/new/page.tsx b/src/app/portfolio/tags/new/page.tsx index 8fc36fa..f7008b7 100644 --- a/src/app/portfolio/tags/new/page.tsx +++ b/src/app/portfolio/tags/new/page.tsx @@ -1,5 +1,10 @@ +import NewTagForm from "@/components/portfolio/tags/NewTagForm"; + export default function PortfolioTagsNewPage() { return ( -
PortfolioTagsNewPage
+
+

New Tag

+ +
); } \ No newline at end of file diff --git a/src/app/portfolio/tags/page.tsx b/src/app/portfolio/tags/page.tsx index 1f59bfa..2be78e2 100644 --- a/src/app/portfolio/tags/page.tsx +++ b/src/app/portfolio/tags/page.tsx @@ -1,5 +1,20 @@ -export default function PortfolioTagsPage() { +import ItemList from "@/components/portfolio/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.portfolioTag.findMany({}) + return ( -
PortfolioTagsPage
+
+
+

Art Tags

+ + Add new tag + +
+ {items && items.length > 0 ? :

There are no tags yet. Consider adding some!

} +
); } \ No newline at end of file diff --git a/src/app/portfolio/types/[id]/page.tsx b/src/app/portfolio/types/[id]/page.tsx index fa1b609..5fb253c 100644 --- a/src/app/portfolio/types/[id]/page.tsx +++ b/src/app/portfolio/types/[id]/page.tsx @@ -1,5 +1,18 @@ -export default function PortfolioTypesEditPage() { +import EditTypeForm from "@/components/portfolio/types/EditTypeForm"; +import prisma from "@/lib/prisma"; + +export default async function PortfolioTypesEditPage({ params }: { params: { id: string } }) { + const { id } = await params; + const type = await prisma.portfolioType.findUnique({ + where: { + id, + } + }) + return ( -
PortfolioTypesEditPage
+
+

Edit Type

+ {type && } +
); } \ No newline at end of file diff --git a/src/app/portfolio/types/new/page.tsx b/src/app/portfolio/types/new/page.tsx index d62a762..518ee3e 100644 --- a/src/app/portfolio/types/new/page.tsx +++ b/src/app/portfolio/types/new/page.tsx @@ -1,5 +1,10 @@ +import NewTypeForm from "@/components/portfolio/types/NewTypeForm"; + export default function PortfolioTypesNewPage() { return ( -
PortfolioTypesNewPage
+
+

New Type

+ +
); } \ No newline at end of file diff --git a/src/app/portfolio/types/page.tsx b/src/app/portfolio/types/page.tsx index 6214c03..36a5a9b 100644 --- a/src/app/portfolio/types/page.tsx +++ b/src/app/portfolio/types/page.tsx @@ -1,5 +1,20 @@ -export default function PortfolioTypesPage() { +import ItemList from "@/components/portfolio/ItemList"; +import prisma from "@/lib/prisma"; +import { PlusCircleIcon } from "lucide-react"; +import Link from "next/link"; + +export default async function PortfolioTypesPage() { + const items = await prisma.portfolioType.findMany({}) + return ( -
PortfolioTypesPage
+
+
+

Art Types

+ + Add new type + +
+ {items && items.length > 0 ? :

There are no types yet. Consider adding some!

} +
); } \ No newline at end of file diff --git a/src/components/portfolio/ItemList.tsx b/src/components/portfolio/ItemList.tsx new file mode 100644 index 0000000..c132c5b --- /dev/null +++ b/src/components/portfolio/ItemList.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { deleteItems } from "@/actions/portfolio/deleteItem"; +import { sortItems } from "@/actions/portfolio/sortItems"; +import { SortableItem as ItemType } from "@/types/SortableItem"; +import { useEffect, useState } from "react"; +import { SortableItem } from "../sort/items/SortableItem"; +import SortableList from "../sort/lists/SortableList"; + +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 sortableItems: ItemType[] = items.map(item => ({ + id: item.id, + sortIndex: item.sortIndex, + label: item.name || "", + })); + + const handleReorder = async (items: ItemType[]) => { + await sortItems(items, type); + }; + + const handleDelete = (id: string) => { + deleteItems(id, type); + }; + + if (!isMounted) return null; + + return ( +
+ { + const it = items.find(g => g.id === item.id)!; + return ( + handleDelete(it.id)} + /> + ); + }} + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/portfolio/categories/EditCategoryForm.tsx b/src/components/portfolio/categories/EditCategoryForm.tsx new file mode 100644 index 0000000..48001bc --- /dev/null +++ b/src/components/portfolio/categories/EditCategoryForm.tsx @@ -0,0 +1,91 @@ +"use client" + +import { updateCategory } from "@/actions/portfolio/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 { PortfolioCategory } from "@/generated/prisma"; +import { categorySchema } from "@/schemas/portfolio/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: PortfolioCategory }) { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(categorySchema), + defaultValues: { + name: category.name, + slug: category.slug, + description: category.description || "", + } + }) + + async function onSubmit(values: z.infer) { + 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 ( +
+
+ + {/* String */} + ( + + Name + + + + + + )} + /> + ( + + Slug + + + + + + )} + /> + ( + + Description + +