Refactor portfolio

This commit is contained in:
2025-07-21 23:45:39 +02:00
parent 312b2c2f94
commit a8d5dbaa09
31 changed files with 1111 additions and 30 deletions

View File

@ -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
}

View File

@ -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<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.portfolioCategory.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return updated
}

View File

@ -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 };
}

View File

@ -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
}
});

View File

@ -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;
}
}

View File

@ -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
}

View File

@ -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<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.portfolioTag.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return updated
}

View File

@ -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
}

View File

@ -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<typeof typeSchema>) {
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
}

View File

@ -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 (
<div>PortfolioCategoriesEditPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">Edit Category</h1>
{category && <EditCategoryForm category={category} />}
</div>
);
}

View File

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

View File

@ -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 (
<div>PortfolioCategoriesPage</div>
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Categories</h1>
<Link href="/portfolio/categories/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new category
</Link>
</div>
{items && items.length > 0 ? <ItemList items={items} type="categories" /> : <p>There are no categories yet. Consider adding some!</p>}
</div>
);
}

View File

@ -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 (
<div>
<div className="flex gap-4 justify-between pb-8">
<div className="flex justify-between pb-4 items-end">
<h1 className="text-2xl font-bold mb-4">Images</h1>
<Link href="/portfolio/images/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" /> Upload new image
</Link>
</div>
{images && images.length > 0 ? <ImageList images={images} /> : <p>There are no images yet. Consider adding some!</p>}
<FilterBar types={types} currentType={typeFilter} currentPublished={publishedFilter} />
<div className="mt-6">
{images && images.length > 0 ? <ImageList images={images} /> : <p>There are no images yet. Consider adding some!</p>}
</div>
</div>
);
}

View File

@ -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 (
<div>PortfolioTagsEditPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">Edit Tag</h1>
{tag && <EditTagForm tag={tag} />}
</div>
);
}

View File

@ -1,5 +1,10 @@
import NewTagForm from "@/components/portfolio/tags/NewTagForm";
export default function PortfolioTagsNewPage() {
return (
<div>PortfolioTagsNewPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">New Tag</h1>
<NewTagForm />
</div>
);
}

View File

@ -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 (
<div>PortfolioTagsPage</div>
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Tags</h1>
<Link href="/portfolio/tags/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new tag
</Link>
</div>
{items && items.length > 0 ? <ItemList items={items} type="tags" /> : <p>There are no tags yet. Consider adding some!</p>}
</div>
);
}

View File

@ -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 (
<div>PortfolioTypesEditPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">Edit Type</h1>
{type && <EditTypeForm type={type} />}
</div>
);
}

View File

@ -1,5 +1,10 @@
import NewTypeForm from "@/components/portfolio/types/NewTypeForm";
export default function PortfolioTypesNewPage() {
return (
<div>PortfolioTypesNewPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">New Type</h1>
<NewTypeForm />
</div>
);
}

View File

@ -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 (
<div>PortfolioTypesPage</div>
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Types</h1>
<Link href="/portfolio/types/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 type
</Link>
</div>
{items && items.length > 0 ? <ItemList items={items} type="types" /> : <p>There are no types yet. Consider adding some!</p>}
</div>
);
}

View File

@ -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 (
<div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
renderItem={(item) => {
const it = items.find(g => g.id === item.id)!;
return (
<SortableItem
key={it.id}
id={it.id}
item={
{
id: it.id,
name: it.name,
href: `/portfolio/${type}/${it.id}`,
type: 'items'
}
}
onDelete={() => handleDelete(it.id)}
/>
);
}}
/>
</div>
);
}

View File

@ -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<z.infer<typeof categorySchema>>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: category.name,
slug: category.slug,
description: category.description || "",
}
})
async function onSubmit(values: z.infer<typeof categorySchema>) {
try {
const updated = await updateCategory(category.id, values)
console.log("Art category updated:", updated)
toast("Art category updated.")
router.push("/portfolio/categories")
} catch (err) {
console.error(err)
toast("Failed to update art category.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} placeholder="The slug shown in the navigation" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,91 @@
"use client"
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/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 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("/portfolio/categories")
} catch (err) {
console.error(err)
toast("Failed to create art category.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} placeholder="The slug shown in the navigation" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -47,10 +47,10 @@ export default function EditImageForm({ image, categories, tags, types }:
defaultValues: {
fileKey: image.fileKey,
originalFile: image.originalFile,
nsfw: image.nsfw ?? false,
published: image.nsfw ?? false,
setAsHeader: image.setAsHeader ?? false,
name: image.name,
nsfw: image.nsfw ?? false,
published: image.published ?? false,
setAsHeader: image.setAsHeader ?? false,
altText: image.altText || "",
description: image.description || "",
@ -72,7 +72,7 @@ export default function EditImageForm({ image, categories, tags, types }:
const updatedImage = await updateImage(values, image.id)
if (updatedImage) {
toast.success("Image updated")
router.push(`/portfolio`)
router.push(`/portfolio/images`)
}
}

View File

@ -0,0 +1,97 @@
"use client"
import { PortfolioType } from "@/generated/prisma";
import { usePathname, useRouter } from "next/navigation";
export default function FilterBar({
types,
currentType,
currentPublished,
}: {
types: PortfolioType[];
currentType: string;
currentPublished: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = new URLSearchParams();
const setFilter = (key: string, value: string) => {
if (value !== "all") {
searchParams.set(key, value);
} else {
searchParams.delete(key);
}
router.push(`${pathname}?${searchParams.toString()}`);
};
return (
<div className="flex flex-wrap gap-4 border-b pb-4">
{/* Type Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Type:</span>
<FilterButton
active={currentType === "all"}
label="All"
onClick={() => setFilter("type", "all")}
/>
{types.map((type) => (
<FilterButton
key={type.id}
active={currentType === type.id}
label={type.name}
onClick={() => setFilter("type", type.id)}
/>
))}
<FilterButton
active={currentType === "none"}
label="No Type"
onClick={() => setFilter("type", "none")}
/>
</div>
{/* Published Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Status:</span>
<FilterButton
active={currentPublished === "all"}
label="All"
onClick={() => setFilter("published", "all")}
/>
<FilterButton
active={currentPublished === "published"}
label="Published"
onClick={() => setFilter("published", "published")}
/>
<FilterButton
active={currentPublished === "unpublished"}
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
</div>
</div>
);
}
function FilterButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-3 py-1 rounded text-sm border ${active
? "bg-primary text-white border-primary"
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
}`}
>
{label}
</button>
);
}

View File

@ -1,5 +1,6 @@
"use client";
import { deleteImage } from "@/actions/_portfolio/edit/deleteImage";
import { sortImages } from "@/actions/portfolio/images/sortImages";
import { SortableItem } from "@/components/sort/items/SortableItem";
import SortableList from "@/components/sort/lists/SortableList";
@ -24,6 +25,10 @@ export default function ImageList({ images }: { images: PortfolioImage[] }) {
await sortImages(items);
};
const handleDelete = (id: string) => {
deleteImage(id);
};
if (!isMounted) return null;
return (
@ -48,6 +53,7 @@ export default function ImageList({ images }: { images: PortfolioImage[] }) {
type: 'image'
}
}
onDelete={() => handleDelete(image.id)}
/>
);
}}

View File

@ -0,0 +1,91 @@
"use client"
import { updateTag } from "@/actions/portfolio/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 { PortfolioTag } from "@/generated/prisma";
import { tagSchema } from "@/schemas/portfolio/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";
export default function EditTagForm({ tag }: { tag: PortfolioTag }) {
const router = useRouter();
const form = useForm<z.infer<typeof tagSchema>>({
resolver: zodResolver(tagSchema),
defaultValues: {
name: tag.name,
slug: tag.slug,
description: tag.description || "",
}
})
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("/portfolio/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>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,91 @@
"use client"
import { createTag } from "@/actions/portfolio/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 { tagSchema } from "@/schemas/portfolio/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";
export default function NewTagForm() {
const router = useRouter();
const form = useForm<z.infer<typeof tagSchema>>({
resolver: zodResolver(tagSchema),
defaultValues: {
name: "",
slug: "",
description: "",
}
})
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("/portfolio/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>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,91 @@
"use client"
import { updateType } from "@/actions/portfolio/types/updateType";
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 { PortfolioType } from "@/generated/prisma";
import { typeSchema } from "@/schemas/portfolio/typeSchema";
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 EditTypeForm({ type }: { type: PortfolioType }) {
const router = useRouter();
const form = useForm<z.infer<typeof typeSchema>>({
resolver: zodResolver(typeSchema),
defaultValues: {
name: type.name,
slug: type.slug,
description: type.description || "",
}
})
async function onSubmit(values: z.infer<typeof typeSchema>) {
try {
const updated = await updateType(type.id, values)
console.log("Art type updated:", updated)
toast("Art type updated.")
router.push("/portfolio/types")
} catch (err) {
console.error(err)
toast("Failed to update art type.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} placeholder="The slug shown in the navigation" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -0,0 +1,91 @@
"use client"
import { createType } from "@/actions/portfolio/types/createType";
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 { typeSchema } from "@/schemas/portfolio/typeSchema";
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 NewTypeForm() {
const router = useRouter();
const form = useForm<z.infer<typeof typeSchema>>({
resolver: zodResolver(typeSchema),
defaultValues: {
name: "",
slug: "",
description: "",
}
})
async function onSubmit(values: z.infer<typeof typeSchema>) {
try {
const created = await createType(values)
console.log("Art type created:", created)
toast("Art type created.")
router.push("/portfolio/types")
} catch (err) {
console.error(err)
toast("Failed to create art type.")
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input {...field} placeholder="The slug shown in the navigation" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
</div >
);
}

View File

@ -5,9 +5,10 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, PencilIcon } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
type SupportedTypes = 'image' | 'type' | 'category' | 'tag';
type SupportedTypes = 'image' | 'items';
type SortableItemProps = {
id: string;
@ -22,9 +23,10 @@ type SortableItemProps = {
count?: number;
textLabel?: string;
};
onDelete: (itemId: string) => void;
};
export function SortableItem({ id, item }: SortableItemProps) {
export function SortableItem({ id, item, onDelete }: SortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
@ -51,11 +53,11 @@ export function SortableItem({ id, item }: SortableItemProps) {
ref={setNodeRef}
style={style}
{...attributes}
className="relative cursor-grab active:cursor-grabbing"
className="relative active:cursor-grabbing"
>
<div
{...listeners}
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
className="absolute cursor-grab top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
@ -66,7 +68,15 @@ export function SortableItem({ id, item }: SortableItemProps) {
<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
@ -78,6 +88,13 @@ export function SortableItem({ id, item }: SortableItemProps) {
Edit
</Button>
</Link>
<Button
variant="destructive"
className="w-full"
onClick={() => onDelete(item.id)}
>
Delete
</Button>
</CardFooter>
</Card>
</div>

View File

@ -3,11 +3,11 @@ import { GetObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "stream";
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({
Bucket: "gaertan",
Key: `original/${fileKey}.${type}`,
Key: `original/${fileKey}.${fileType}`,
});
const response = await s3.send(command);