Refactor portfolio
This commit is contained in:
		
							
								
								
									
										25
									
								
								src/actions/portfolio/categories/createCategory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/actions/portfolio/categories/createCategory.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/actions/portfolio/categories/updateCategory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/actions/portfolio/categories/updateCategory.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/actions/portfolio/deleteItem.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/actions/portfolio/deleteItem.ts
									
									
									
									
									
										Normal 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 };
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										40
									
								
								src/actions/portfolio/sortItems.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/actions/portfolio/sortItems.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								src/actions/portfolio/tags/createTag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/actions/portfolio/tags/createTag.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/actions/portfolio/tags/updateTag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/actions/portfolio/tags/updateTag.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								src/actions/portfolio/types/createType.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/actions/portfolio/types/createType.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/actions/portfolio/types/updateType.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/actions/portfolio/types/updateType.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								src/components/portfolio/ItemList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/portfolio/ItemList.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/categories/EditCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/categories/EditCategoryForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/categories/NewCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/categories/NewCategoryForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										97
									
								
								src/components/portfolio/images/FilterBar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/components/portfolio/images/FilterBar.tsx
									
									
									
									
									
										Normal 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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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)}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/tags/EditTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/tags/EditTagForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/tags/NewTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/tags/NewTagForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/types/EditTypeForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/types/EditTypeForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/portfolio/types/NewTypeForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/portfolio/types/NewTypeForm.tsx
									
									
									
									
									
										Normal 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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user