diff --git a/prisma/migrations/20250713200703_set_as_header_toggle/migration.sql b/prisma/migrations/20250713200703_set_as_header_toggle/migration.sql
new file mode 100644
index 0000000..78b6995
--- /dev/null
+++ b/prisma/migrations/20250713200703_set_as_header_toggle/migration.sql
@@ -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");
diff --git a/prisma/migrations/20250713200736_set_as_header_toggle/migration.sql b/prisma/migrations/20250713200736_set_as_header_toggle/migration.sql
new file mode 100644
index 0000000..c2a122e
--- /dev/null
+++ b/prisma/migrations/20250713200736_set_as_header_toggle/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "PortfolioImage" ADD COLUMN "setAsHeader" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/migrations/20250714174114_add_type_fields/migration.sql b/prisma/migrations/20250714174114_add_type_fields/migration.sql
new file mode 100644
index 0000000..58061df
--- /dev/null
+++ b/prisma/migrations/20250714174114_add_type_fields/migration.sql
@@ -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;
diff --git a/prisma/migrations/20250714182458_add_type_fields/migration.sql b/prisma/migrations/20250714182458_add_type_fields/migration.sql
new file mode 100644
index 0000000..54fd5c8
--- /dev/null
+++ b/prisma/migrations/20250714182458_add_type_fields/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5029940..30e695c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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())
diff --git a/src/actions/portfolio/arttypes/createArtType.ts b/src/actions/portfolio/arttypes/createArtType.ts
new file mode 100644
index 0000000..c42ac25
--- /dev/null
+++ b/src/actions/portfolio/arttypes/createArtType.ts
@@ -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
+}
\ No newline at end of file
diff --git a/src/app/portfolio/arttypes/edit/page.tsx b/src/app/portfolio/arttypes/edit/page.tsx
new file mode 100644
index 0000000..a7f05c2
--- /dev/null
+++ b/src/app/portfolio/arttypes/edit/page.tsx
@@ -0,0 +1,5 @@
+export default function ArtTypesEditPage() {
+ return (
+
ArtTypesEditPage
+ );
+}
\ No newline at end of file
diff --git a/src/app/portfolio/arttypes/new/page.tsx b/src/app/portfolio/arttypes/new/page.tsx
new file mode 100644
index 0000000..6d7399c
--- /dev/null
+++ b/src/app/portfolio/arttypes/new/page.tsx
@@ -0,0 +1,12 @@
+import NewArtTypeForm from "@/components/portfolio/arttypes/NewArtTypeForm";
+
+export default function ArtTypesNewPage() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/portfolio/arttypes/page.tsx b/src/app/portfolio/arttypes/page.tsx
new file mode 100644
index 0000000..01b4261
--- /dev/null
+++ b/src/app/portfolio/arttypes/page.tsx
@@ -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 (
+
+
+
Art Types
+
+
Add new type
+
+
+ {items.length > 0 ?
:
No items found.
}
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/portfolio/categories/page.tsx b/src/app/portfolio/categories/page.tsx
new file mode 100644
index 0000000..adcc484
--- /dev/null
+++ b/src/app/portfolio/categories/page.tsx
@@ -0,0 +1,5 @@
+export default function CategoriesPage() {
+ return (
+ CategoriesPage
+ );
+}
\ No newline at end of file
diff --git a/src/app/portfolio/edit/[id]/page.tsx b/src/app/portfolio/edit/[id]/page.tsx
index 273451e..3b42107 100644
--- a/src/app/portfolio/edit/[id]/page.tsx
+++ b/src/app/portfolio/edit/[id]/page.tsx
@@ -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 (
Edit image
- {image ?
: 'Image not found...'}
+ {image ?
: 'Image not found...'}
{image && }
diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx
index 29ed23a..debbe3a 100644
--- a/src/components/global/TopNav.tsx
+++ b/src/components/global/TopNav.tsx
@@ -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() {
Home
+
-
- Portfolio
-
+ Portfolio
+
+
+ -
+
+
+ All Portfolio Images
+
+
+
+ -
+
+
+ Art Types
+
+
+
+ -
+
+
+ Categories
+
+
+
+ -
+
+
+ Tags
+
+
+
+
+
+
CommissionTypes
diff --git a/src/components/portfolio/arttypes/EditArtTypeForm.tsx b/src/components/portfolio/arttypes/EditArtTypeForm.tsx
new file mode 100644
index 0000000..88c3613
--- /dev/null
+++ b/src/components/portfolio/arttypes/EditArtTypeForm.tsx
@@ -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>({
+ 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) {
+ const updatedImage = await updateImage(values, image.id)
+ if (updatedImage) {
+ toast.success("Image updated")
+ router.push(`/portfolio`)
+ }
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/portfolio/arttypes/ListItems.tsx b/src/components/portfolio/arttypes/ListItems.tsx
new file mode 100644
index 0000000..73fbfbf
--- /dev/null
+++ b/src/components/portfolio/arttypes/ListItems.tsx
@@ -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 (
+ {
+ const it = items.find(g => g.id === item.id)!;
+ return (
+
+ );
+ }}
+ />
+ );
+}
\ No newline at end of file
diff --git a/src/components/portfolio/arttypes/NewArtTypeForm.tsx b/src/components/portfolio/arttypes/NewArtTypeForm.tsx
new file mode 100644
index 0000000..5f304a0
--- /dev/null
+++ b/src/components/portfolio/arttypes/NewArtTypeForm.tsx
@@ -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>({
+ resolver: zodResolver(artTypeSchema),
+ defaultValues: {
+ name: "",
+ slug: "",
+ description: "",
+ }
+ })
+
+ async function onSubmit(values: z.infer) {
+ 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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/portfolio/arttypes/SortableItem.tsx b/src/components/portfolio/arttypes/SortableItem.tsx
new file mode 100644
index 0000000..cfc3fc0
--- /dev/null
+++ b/src/components/portfolio/arttypes/SortableItem.tsx
@@ -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 = {
+// 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 (
+
+
+
+
+
+
+
+
+
+ {item.name}
+
+ {countLabel && (
+
{countLabel}
+ )}
+ {item.textLabel && (
+
{item.textLabel}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/portfolio/edit/EditImageForm.tsx b/src/components/portfolio/edit/EditImageForm.tsx
index 7742973..88c3613 100644
--- a/src/components/portfolio/edit/EditImageForm.tsx
+++ b/src/components/portfolio/edit/EditImageForm.tsx
@@ -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>({
@@ -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 }: