Add art types and categories

This commit is contained in:
2025-07-20 10:07:09 +02:00
parent b2c77ec9e0
commit 92081828f0
20 changed files with 989 additions and 103 deletions

View File

@ -0,0 +1,22 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `PortfolioCategory` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[name]` on the table `PortfolioTag` will be added. If there are existing duplicate values, this will fail.
- Made the column `name` on table `PortfolioCategory` required. This step will fail if there are existing NULL values in that column.
- Made the column `name` on table `PortfolioTag` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "PortfolioCategory" ADD COLUMN "description" TEXT,
ALTER COLUMN "name" SET NOT NULL;
-- AlterTable
ALTER TABLE "PortfolioTag" ADD COLUMN "description" TEXT,
ALTER COLUMN "name" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioCategory_name_key" ON "PortfolioCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioTag_name_key" ON "PortfolioTag"("name");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PortfolioImage" ADD COLUMN "setAsHeader" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "PortfolioImage" ADD COLUMN "artType" TEXT,
ADD COLUMN "group" TEXT,
ADD COLUMN "kind" TEXT,
ADD COLUMN "layoutGroup" TEXT,
ADD COLUMN "layoutOrder" INTEGER,
ADD COLUMN "month" INTEGER,
ADD COLUMN "series" TEXT,
ADD COLUMN "year" INTEGER;

View File

@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the column `artType` on the `PortfolioImage` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PortfolioImage" DROP COLUMN "artType",
ADD COLUMN "artTypeId" TEXT;
-- CreateTable
CREATE TABLE "PortfolioArtType" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"slug" TEXT,
"description" TEXT,
CONSTRAINT "PortfolioArtType_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioArtType_name_key" ON "PortfolioArtType"("name");
-- AddForeignKey
ALTER TABLE "PortfolioImage" ADD CONSTRAINT "PortfolioImage_artTypeId_fkey" FOREIGN KEY ("artTypeId") REFERENCES "PortfolioArtType"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -160,24 +160,47 @@ model PortfolioImage {
originalFile String @unique
nsfw Boolean @default(false)
published Boolean @default(false)
setAsHeader Boolean @default(false)
altText String?
description String?
fileType String?
group String?
kind String?
layoutGroup String?
name String?
series String?
slug String?
type String?
fileSize Int?
layoutOrder Int?
month Int?
year Int?
creationDate DateTime?
metadata ImageMetadata?
artTypeId String?
artType PortfolioArtType? @relation(fields: [artTypeId], references: [id])
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioArtType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String?
description String?
images PortfolioImage[]
}
model PortfolioCategory {
id String @id @default(cuid())
createdAt DateTime @default(now())

View File

@ -0,0 +1,23 @@
import prisma from '@/lib/prisma';
import { artTypeSchema } from '@/schemas/artTypeSchema';
export async function createArtType(formData: artTypeSchema) {
const parsed = artTypeSchema.safeParse(formData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const created = await prisma.portfolioArtType.create({
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return created
}

View File

@ -0,0 +1,5 @@
export default function ArtTypesEditPage() {
return (
<div>ArtTypesEditPage</div>
);
}

View File

@ -0,0 +1,12 @@
import NewArtTypeForm from "@/components/portfolio/arttypes/NewArtTypeForm";
export default function ArtTypesNewPage() {
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Art Type</h1>
</div>
<NewArtTypeForm />
</div>
);
}

View File

@ -0,0 +1,27 @@
import ListItems from "@/components/portfolio/arttypes/ListItems";
import prisma from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function ArtTypesPage() {
const items = await prisma.portfolioArtType.findMany(
{
orderBy: [
{ sortIndex: 'asc' },
{ name: 'asc' }
]
}
);
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Art Types</h1>
<Link href="/portfolio/arttypes/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.length > 0 ? <ListItems items={items} /> : <p className="text-muted-foreground italic">No items found.</p>}
</div>
);
}

View File

@ -0,0 +1,5 @@
export default function CategoriesPage() {
return (
<div>CategoriesPage</div>
);
}

View File

@ -20,13 +20,14 @@ export default async function PortfolioEditPage({ params }: { params: { id: stri
const categories = await prisma.portfolioCategory.findMany({ orderBy: { name: "asc" } });
const tags = await prisma.portfolioTag.findMany({ orderBy: { name: "asc" } });
const artTypes = await prisma.portfolioArtType.findMany({ orderBy: { name: "asc" } });
return (
<div>
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
{image ? <EditImageForm image={image} tags={tags} categories={categories} /> : 'Image not found...'}
{image ? <EditImageForm image={image} tags={tags} categories={categories} artTypes={artTypes} /> : 'Image not found...'}
<div className="mt-6">
{image && <DeleteImageButton imageId={image.id} />}
</div>

View File

@ -1,6 +1,6 @@
"use client"
import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import Link from "next/link";
export default function TopNav() {
@ -12,11 +12,43 @@ export default function TopNav() {
<Link href="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/portfolio">Portfolio</Link>
</NavigationMenuLink>
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid gap-2 p-4 w-48">
<li>
<NavigationMenuLink asChild>
<Link href="/portfolio" className="block px-2 py-1 rounded hover:bg-muted">
All Portfolio Images
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link href="/portfolio/arttypes" className="block px-2 py-1 rounded hover:bg-muted">
Art Types
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link href="/portfolio/categories" className="block px-2 py-1 rounded hover:bg-muted">
Categories
</Link>
</NavigationMenuLink>
</li>
<li>
<NavigationMenuLink asChild>
<Link href="/portfolio/tags" className="block px-2 py-1 rounded hover:bg-muted">
Tags
</Link>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/items/commissions/types">CommissionTypes</Link>

View File

@ -0,0 +1,384 @@
"use client"
import { updateImage } from "@/actions/portfolio/edit/updateImage";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioArtType, PortfolioCategory, PortfolioImage, PortfolioTag } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
type ImageWithItems = PortfolioImage & {
metadata: ImageMetadata | null,
colors: (
ImageColor & {
color: Color
}
)[],
variants: ImageVariant[],
categories: PortfolioCategory[],
tags: PortfolioTag[],
artTypes: PortfolioArtType[],
};
export default function EditImageForm({ image, categories, tags, artTypes }:
{
image: ImageWithItems,
categories: PortfolioCategory[]
tags: PortfolioTag[],
artTypes: PortfolioArtType[]
}) {
const router = useRouter();
const form = useForm<z.infer<typeof imageSchema>>({
resolver: zodResolver(imageSchema),
defaultValues: {
fileKey: image.fileKey,
originalFile: image.originalFile,
nsfw: image.nsfw ?? false,
published: image.nsfw ?? false,
setAsHeader: image.setAsHeader ?? false,
altText: image.altText || "",
description: image.description || "",
fileType: image.fileType || "",
group: image.group || "",
kind: image.kind || "",
layoutGroup: image.layoutGroup || "",
name: image.name || "",
series: image.series || "",
slug: image.slug || "",
type: image.type || "",
fileSize: image.fileSize || undefined,
layoutOrder: image.layoutOrder || undefined,
month: image.month || undefined,
year: image.year || undefined,
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
categoryIds: image.categories?.map(cat => cat.id) ?? [],
}
})
async function onSubmit(values: z.infer<typeof imageSchema>) {
const updatedImage = await updateImage(values, image.id)
if (updatedImage) {
toast.success("Image updated")
router.push(`/portfolio`)
}
}
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>Image name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="altText"
render={({ field }) => (
<FormItem>
<FormLabel>Alt Text</FormLabel>
<FormControl>
<Input {...field} placeholder="Alt for this image" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Number */}
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem>
<FormLabel>Creation Month</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem>
<FormLabel>Creation Year</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Date */}
<FormField
control={form.control}
name="creationDate"
render={({ field }) => (
<FormItem className="flex flex-col gap-1">
<FormLabel>Creation Date</FormLabel>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? format(field.value, "PPP") : "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date)
}}
initialFocus
fromYear={1990}
toYear={2030}
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
</div>
<FormMessage />
</FormItem>
)}
/>
{/* Select */}
<FormField
control={form.control}
name="artTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Art Type</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an art type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{artTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedOptions = tags
.filter(tag => field.value?.includes(tag.id))
.map(tag => ({ label: tag.name, value: tag.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={tags.map(tag => ({
label: tag.name,
value: tag.id,
}))}
placeholder="Select tags"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
name="categoryIds"
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>
)
}}
/>
{/* Boolean */}
<FormField
control={form.control}
name="nsfw"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="setAsHeader"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Set as header image</FormLabel>
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{/* Read only */}
<FormField
control={form.control}
name="fileKey"
render={({ field }) => (
<FormItem>
<FormLabel>Image Key</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="originalFile"
render={({ field }) => (
<FormItem>
<FormLabel>Original file</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileType"
render={({ field }) => (
<FormItem>
<FormLabel>Filetype</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileSize"
render={({ field }) => (
<FormItem>
<FormLabel>FileSize</FormLabel>
<FormControl><Input type="number" {...field} disabled /></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,55 @@
"use client"
import { updateImageSortOrder } from "@/actions/portfolio/updateImageSortOrder";
import { SortableList } from "@/components/sort/SortableList";
import { PortfolioArtType } from "@/generated/prisma";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
import { SortableCardItem } from "./SortableItem";
export default function ListItems({ items }: { items: PortfolioArtType[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = items.map(item => ({
id: item.id,
sortIndex: item.sortIndex,
label: item.name || "",
}));
// const handleSortDefault = async () => {
// const sorted = [...sortableItems]
// .sort((a, b) => a.label.localeCompare(b.label))
// .map((item, index) => ({ ...item, sortIndex: index * 10 }));
// await updateImageSortOrder(sorted);
// };
const handleReorder = async (items: SortableItem[]) => {
await updateImageSortOrder(items);
};
if (!isMounted) return null;
return (
<SortableList
items={sortableItems}
onReorder={handleReorder}
renderItem={(item) => {
const it = items.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={it.id}
item={{
id: it.id,
name: it.name || ""
}}
/>
);
}}
/>
);
}

View File

@ -0,0 +1,91 @@
"use client"
import { createArtType } from "@/actions/portfolio/arttypes/createArtType";
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 { artTypeSchema } from "@/schemas/artTypeSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
export default function NewArtTypeForm() {
const router = useRouter();
const form = useForm<z.infer<typeof artTypeSchema>>({
resolver: zodResolver(artTypeSchema),
defaultValues: {
name: "",
slug: "",
description: "",
}
})
async function onSubmit(values: z.infer<typeof artTypeSchema>) {
try {
const created = await createArtType(values)
console.log("CommissionType created:", created)
toast("Commission type created.")
router.push("/items/commissions/types")
} catch (err) {
console.error(err)
toast("Failed to create commission 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 sowhn 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 to the image" />
</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,76 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from 'clsx';
import { GripVertical } from 'lucide-react';
import Link from 'next/link';
// type SupportedTypes = 'gallery' | 'album' | 'artist' | 'category' | 'tag';
// const pluralMap: Record<SupportedTypes, string> = {
// gallery: 'galleries',
// album: 'albums',
// artist: 'artists',
// category: 'categories',
// tag: 'tags',
// };
type SortableCardItemProps = {
id: string;
item: {
id: string;
name: string;
count?: number;
textLabel?: string;
};
};
export function SortableCardItem({ id, item }: SortableCardItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const href = `/portfolio/arttype/edit/${item.id}`;
let countLabel = '';
if (item.count !== undefined) {
countLabel = `${item.count} image${item.count !== 1 ? 's' : ''}`;
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="relative cursor-grab active:cursor-grabbing"
>
<div
{...listeners}
className="absolute 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" />
</div>
<Link href={href}>
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
<div className={clsx("p-4 text-center")}>
<h2 className={clsx("text-lg font-semibold truncate text-center")}>
{item.name}
</h2>
{countLabel && (
<p className="text-sm text-muted-foreground mt-1">{countLabel}</p>
)}
{item.textLabel && (
<p className="text-sm text-muted-foreground mt-1">{item.textLabel}</p>
)}
</div>
</div>
</Link>
</div>
);
}

View File

@ -7,9 +7,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag } from "@/generated/prisma";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioArtType, PortfolioCategory, PortfolioImage, PortfolioTag } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
@ -29,14 +30,16 @@ type ImageWithItems = PortfolioImage & {
variants: ImageVariant[],
categories: PortfolioCategory[],
tags: PortfolioTag[],
artTypes: PortfolioArtType[],
};
export default function EditImageForm({ image, categories, tags }:
export default function EditImageForm({ image, categories, tags, artTypes }:
{
image: ImageWithItems,
categories: PortfolioCategory[]
tags: PortfolioTag[],
artTypes: PortfolioArtType[]
}) {
const router = useRouter();
const form = useForm<z.infer<typeof imageSchema>>({
@ -46,14 +49,22 @@ export default function EditImageForm({ image, categories, tags }:
originalFile: image.originalFile,
nsfw: image.nsfw ?? false,
published: image.nsfw ?? false,
setAsHeader: image.setAsHeader ?? false,
altText: image.altText || "",
description: image.description || "",
fileType: image.fileType || "",
group: image.group || "",
kind: image.kind || "",
layoutGroup: image.layoutGroup || "",
name: image.name || "",
series: image.series || "",
slug: image.slug || "",
type: image.type || "",
fileSize: image.fileSize || undefined,
layoutOrder: image.layoutOrder || undefined,
month: image.month || undefined,
year: image.year || undefined,
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
@ -73,66 +84,29 @@ export default function EditImageForm({ image, categories, tags }:
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */}
<FormField
control={form.control}
name="fileKey"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Image Key</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormLabel>Image name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="originalFile"
render={({ field }) => (
<FormItem>
<FormLabel>Original file</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nsfw"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="altText"
render={({ field }) => (
<FormItem>
<FormLabel>Alt Text</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormControl>
<Input {...field} placeholder="Alt for this image" />
</FormControl>
<FormMessage />
</FormItem>
)}
@ -143,66 +117,41 @@ export default function EditImageForm({ image, categories, tags }:
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Number */}
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem>
<FormLabel>Creation Month</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
name="year"
render={({ field }) => (
<FormItem>
<FormLabel>Image name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Image slug</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Image type</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileType"
render={({ field }) => (
<FormItem>
<FormLabel>Filetype</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileSize"
render={({ field }) => (
<FormItem>
<FormLabel>Image fileSize</FormLabel>
<FormControl><Input type="number" {...field} disabled /></FormControl>
<FormLabel>Creation Year</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Date */}
<FormField
control={form.control}
name="creationDate"
@ -243,6 +192,34 @@ export default function EditImageForm({ image, categories, tags }:
</FormItem>
)}
/>
{/* Select */}
<FormField
control={form.control}
name="artTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Art Type</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an art type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{artTypes.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagIds"
@ -305,6 +282,97 @@ export default function EditImageForm({ image, categories, tags }:
)
}}
/>
{/* Boolean */}
<FormField
control={form.control}
name="nsfw"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="setAsHeader"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Set as header image</FormLabel>
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{/* Read only */}
<FormField
control={form.control}
name="fileKey"
render={({ field }) => (
<FormItem>
<FormLabel>Image Key</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="originalFile"
render={({ field }) => (
<FormItem>
<FormLabel>Original file</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileType"
render={({ field }) => (
<FormItem>
<FormLabel>Filetype</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileSize"
render={({ field }) => (
<FormItem>
<FormLabel>FileSize</FormLabel>
<FormControl><Input type="number" {...field} disabled /></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>

View File

@ -0,0 +1,5 @@
export default function TagsPage() {
return (
<div>TagsPage</div>
);
}

View File

@ -0,0 +1,9 @@
import * as z from "zod/v4";
export const artTypeSchema = z.object({
name: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
description: z.string().optional(),
})
export type artTypeSchema = z.infer<typeof artTypeSchema>

View File

@ -13,16 +13,25 @@ export const imageSchema = z.object({
originalFile: z.string().min(1, "Original file is required"),
nsfw: z.boolean(),
published: z.boolean(),
setAsHeader: z.boolean(),
altText: z.string().optional(),
description: z.string().optional(),
fileType: z.string().optional(),
group: z.string().optional(),
kind: z.string().optional(),
layoutGroup: z.string().optional(),
name: z.string().optional(),
series: z.string().optional(),
slug: z.string().optional(),
type: z.string().optional(),
fileSize: z.number().optional(),
layoutOrder: z.number().optional(),
month: z.number().optional(),
year: z.number().optional(),
creationDate: z.date().optional(),
categoryIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
artTypeId: z.string().optional(),
})