Add albums
This commit is contained in:
		
							
								
								
									
										25
									
								
								src/actions/portfolio/albums/createAlbum.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/actions/portfolio/albums/createAlbum.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,25 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from '@/lib/prisma';
 | 
			
		||||
import { albumSchema } from '@/schemas/portfolio/albumSchema';
 | 
			
		||||
 | 
			
		||||
export async function createAlbum(formData: albumSchema) {
 | 
			
		||||
  const parsed = albumSchema.safeParse(formData)
 | 
			
		||||
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    console.error("Validation failed", parsed.error)
 | 
			
		||||
    throw new Error("Invalid input")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = parsed.data
 | 
			
		||||
 | 
			
		||||
  const created = await prisma.portfolioAlbum.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      slug: data.slug,
 | 
			
		||||
      description: data.description
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return created
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								src/actions/portfolio/albums/updateAlbum.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/actions/portfolio/albums/updateAlbum.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from '@/lib/prisma';
 | 
			
		||||
import { albumSchema } from '@/schemas/portfolio/albumSchema';
 | 
			
		||||
import { z } from 'zod/v4';
 | 
			
		||||
 | 
			
		||||
export async function updateAlbum(id: string, rawData: z.infer<typeof albumSchema>) {
 | 
			
		||||
  const parsed = albumSchema.safeParse(rawData)
 | 
			
		||||
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    console.error("Validation failed", parsed.error)
 | 
			
		||||
    throw new Error("Invalid input")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = parsed.data
 | 
			
		||||
 | 
			
		||||
  const updated = await prisma.portfolioAlbum.update({
 | 
			
		||||
    where: { id },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      slug: data.slug,
 | 
			
		||||
      description: data.description
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return updated
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										45
									
								
								src/actions/portfolio/getJustifiedImages.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/actions/portfolio/getJustifiedImages.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function getJustifiedImages() {
 | 
			
		||||
  const images = await prisma.portfolioImage.findMany({
 | 
			
		||||
    where: {
 | 
			
		||||
      variants: {
 | 
			
		||||
        some: { type: "resized" },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      variants: true,
 | 
			
		||||
      colors: { include: { color: true } },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return images
 | 
			
		||||
    .map((img) => {
 | 
			
		||||
      const variant = img.variants.find((v) => v.type === "resized");
 | 
			
		||||
      if (!variant || !variant.width || !variant.height) return null;
 | 
			
		||||
 | 
			
		||||
      const bg = img.colors.find((c) => c.type === "Vibrant")?.color.hex ?? "#e5e7eb";
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        id: img.id,
 | 
			
		||||
        fileKey: img.fileKey,
 | 
			
		||||
        altText: img.altText ?? img.name ?? "",
 | 
			
		||||
        backgroundColor: bg,
 | 
			
		||||
        width: variant.width,
 | 
			
		||||
        height: variant.height,
 | 
			
		||||
        url: variant.url ?? `/api/image/resized/${img.fileKey}.webp`,
 | 
			
		||||
      };
 | 
			
		||||
    })
 | 
			
		||||
    .filter(Boolean) as JustifiedInputImage[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface JustifiedInputImage {
 | 
			
		||||
  id: string;
 | 
			
		||||
  url: string;
 | 
			
		||||
  altText: string;
 | 
			
		||||
  backgroundColor: string;
 | 
			
		||||
  width: number;
 | 
			
		||||
  height: number;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								src/actions/portfolio/images/saveImageLayoutOrder.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/actions/portfolio/images/saveImageLayoutOrder.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,48 @@
 | 
			
		||||
'use server';
 | 
			
		||||
 | 
			
		||||
import prisma from '@/lib/prisma';
 | 
			
		||||
import { z } from 'zod/v4';
 | 
			
		||||
 | 
			
		||||
const ImageLayoutOrderSchema = z.object({
 | 
			
		||||
  highlighted: z.array(z.string().cuid()),
 | 
			
		||||
  featured: z.array(z.string().cuid()),
 | 
			
		||||
  default: z.array(z.string().cuid()),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export async function saveImageLayoutOrder(input: unknown) {
 | 
			
		||||
  const parsed = ImageLayoutOrderSchema.safeParse(input);
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    return { success: false, error: parsed.error.flatten() };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { highlighted, featured, default: defaultGroup } = parsed.data;
 | 
			
		||||
 | 
			
		||||
  const updates = [
 | 
			
		||||
    ...highlighted.map((id, i) => ({
 | 
			
		||||
      id,
 | 
			
		||||
      layoutGroup: 'highlighted' as const,
 | 
			
		||||
      layoutOrder: i,
 | 
			
		||||
    })),
 | 
			
		||||
    ...featured.map((id, i) => ({
 | 
			
		||||
      id,
 | 
			
		||||
      layoutGroup: 'featured' as const,
 | 
			
		||||
      layoutOrder: i,
 | 
			
		||||
    })),
 | 
			
		||||
    ...defaultGroup.map((id, i) => ({
 | 
			
		||||
      id,
 | 
			
		||||
      layoutGroup: 'default' as const,
 | 
			
		||||
      layoutOrder: i,
 | 
			
		||||
    })),
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  const tx = updates.map(({ id, layoutGroup, layoutOrder }) =>
 | 
			
		||||
    prisma.portfolioImage.update({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      data: { layoutGroup, layoutOrder },
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  await prisma.$transaction(tx);
 | 
			
		||||
 | 
			
		||||
  return { success: true };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								src/app/portfolio/albums/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/app/portfolio/albums/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
import EditAlbumForm from "@/components/portfolio/albums/EditAlbumForm";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function PortfolioAlbumsEditPage({ params }: { params: { id: string } }) {
 | 
			
		||||
  const { id } = await params;
 | 
			
		||||
  const album = await prisma.portfolioAlbum.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">Edit Album</h1>
 | 
			
		||||
      {album && <EditAlbumForm album={album} />}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/portfolio/albums/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/portfolio/albums/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import NewAlbumForm from "@/components/portfolio/albums/NewAlbumForm";
 | 
			
		||||
 | 
			
		||||
export default function PortfolioAlbumsNewPage() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">New Album</h1>
 | 
			
		||||
      <NewAlbumForm />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/app/portfolio/albums/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/app/portfolio/albums/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
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 PortfolioAlbumsPage() {
 | 
			
		||||
  const items = await prisma.portfolioAlbum.findMany({})
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <div className="flex gap-4 justify-between pb-8">
 | 
			
		||||
        <h1 className="text-2xl font-bold mb-4">Albums</h1>
 | 
			
		||||
        <Link href="/portfolio/albums/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 album
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
      {items && items.length > 0 ? <ItemList items={items} type="albums" /> : <p>There are no albums yet. Consider adding some!</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { AdvancedMosaicGallery } from "@/components/portfolio/images/AdvancedMosaicGallery";
 | 
			
		||||
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";
 | 
			
		||||
@ -23,7 +23,7 @@ export default async function PortfolioImagesPage({
 | 
			
		||||
    groupBy = "year",
 | 
			
		||||
    year,
 | 
			
		||||
    album,
 | 
			
		||||
  } = searchParams ?? {};
 | 
			
		||||
  } = await searchParams ?? {};
 | 
			
		||||
 | 
			
		||||
  const groupMode = groupBy === "album" ? "album" : "year";
 | 
			
		||||
  const groupId = groupMode === "album" ? album ?? "all" : year ?? "all";
 | 
			
		||||
@ -90,7 +90,13 @@ export default async function PortfolioImagesPage({
 | 
			
		||||
        groupId={groupId}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="mt-6">
 | 
			
		||||
        {images && images.length > 0 ? <ImageList images={images} /> : <p>There are no images yet. Consider adding some!</p>}
 | 
			
		||||
        {images && images.length > 0 ? <AdvancedMosaicGallery
 | 
			
		||||
          images={images.map((img) => ({
 | 
			
		||||
            ...img,
 | 
			
		||||
            width: 400,
 | 
			
		||||
            height: 300,
 | 
			
		||||
          }))}
 | 
			
		||||
        /> : <p className="text-muted-foreground italic">No images found.</p>}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,29 @@
 | 
			
		||||
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
const portfolioItems = [
 | 
			
		||||
  {
 | 
			
		||||
    title: "Images",
 | 
			
		||||
    href: "/portfolio/images",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Types",
 | 
			
		||||
    href: "/portfolio/types",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Albums",
 | 
			
		||||
    href: "/portfolio/albums",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Categories",
 | 
			
		||||
    href: "/portfolio/categories",
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    title: "Tags",
 | 
			
		||||
    href: "/portfolio/tags",
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export default function TopNav() {
 | 
			
		||||
  return (
 | 
			
		||||
    <NavigationMenu viewport={false}>
 | 
			
		||||
@ -17,42 +40,17 @@ export default function TopNav() {
 | 
			
		||||
          <NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
 | 
			
		||||
          <NavigationMenuContent>
 | 
			
		||||
            <ul className="grid w-[300px] gap-4">
 | 
			
		||||
              <li>
 | 
			
		||||
                <NavigationMenuLink asChild>
 | 
			
		||||
                  <Link href="/portfolio/images">
 | 
			
		||||
                    <div className="text-sm leading-none font-medium">Images</div>
 | 
			
		||||
                    <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
 | 
			
		||||
                    </p>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </NavigationMenuLink>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li>
 | 
			
		||||
                <NavigationMenuLink asChild>
 | 
			
		||||
                  <Link href="/portfolio/types">
 | 
			
		||||
                    <div className="text-sm leading-none font-medium">Types</div>
 | 
			
		||||
                    <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
 | 
			
		||||
                    </p>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </NavigationMenuLink>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li>
 | 
			
		||||
                <NavigationMenuLink asChild>
 | 
			
		||||
                  <Link href="/portfolio/categories">
 | 
			
		||||
                    <div className="text-sm leading-none font-medium">Categories</div>
 | 
			
		||||
                    <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
 | 
			
		||||
                    </p>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </NavigationMenuLink>
 | 
			
		||||
              </li>
 | 
			
		||||
              <li>
 | 
			
		||||
                <NavigationMenuLink asChild>
 | 
			
		||||
                  <Link href="/portfolio/tags">
 | 
			
		||||
                    <div className="text-sm leading-none font-medium">Tags</div>
 | 
			
		||||
                    <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
 | 
			
		||||
                    </p>
 | 
			
		||||
                  </Link>
 | 
			
		||||
                </NavigationMenuLink>
 | 
			
		||||
              </li>
 | 
			
		||||
              {portfolioItems.map((item) => (
 | 
			
		||||
                <li key={item.title}>
 | 
			
		||||
                  <NavigationMenuLink asChild>
 | 
			
		||||
                    <Link href={item.href}>
 | 
			
		||||
                      <div className="text-sm leading-none font-medium">{item.title}</div>
 | 
			
		||||
                      <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
 | 
			
		||||
                      </p>
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </NavigationMenuLink>
 | 
			
		||||
                </li>
 | 
			
		||||
              ))}
 | 
			
		||||
            </ul>
 | 
			
		||||
          </NavigationMenuContent>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										92
									
								
								src/components/portfolio/albums/EditAlbumForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/portfolio/albums/EditAlbumForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { updateAlbum } from "@/actions/portfolio/albums/updateAlbum";
 | 
			
		||||
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 { PortfolioAlbum } from "@/generated/prisma";
 | 
			
		||||
import { albumSchema } from "@/schemas/portfolio/albumSchema";
 | 
			
		||||
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 EditAlbumForm({ album }: { album: PortfolioAlbum }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof albumSchema>>({
 | 
			
		||||
    resolver: zodResolver(albumSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: album.name,
 | 
			
		||||
      slug: album.slug,
 | 
			
		||||
      description: album.description || "",
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof tagSchema>) {
 | 
			
		||||
    try {
 | 
			
		||||
      const updated = await updateAlbum(album.id, values)
 | 
			
		||||
      console.log("Album updated:", updated)
 | 
			
		||||
      toast("Album updated.")
 | 
			
		||||
      router.push("/portfolio/albums")
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err)
 | 
			
		||||
      toast("Failed to update album.")
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										92
									
								
								src/components/portfolio/albums/NewAlbumForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/portfolio/albums/NewAlbumForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { createAlbum } from "@/actions/portfolio/albums/createAlbum";
 | 
			
		||||
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 { albumSchema } from "@/schemas/portfolio/albumSchema";
 | 
			
		||||
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 NewAlbumForm() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof albumSchema>>({
 | 
			
		||||
    resolver: zodResolver(tagSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: "",
 | 
			
		||||
      slug: "",
 | 
			
		||||
      description: "",
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof tagSchema>) {
 | 
			
		||||
    try {
 | 
			
		||||
      const created = await createAlbum(values)
 | 
			
		||||
      console.log("Album created:", created)
 | 
			
		||||
      toast("Album created.")
 | 
			
		||||
      router.push("/portfolio/albums")
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error(err)
 | 
			
		||||
      toast("Failed to create album.")
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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 >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										189
									
								
								src/components/portfolio/images/AdvancedMosaicGallery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/components/portfolio/images/AdvancedMosaicGallery.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,189 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import { saveImageLayoutOrder } from '@/actions/portfolio/images/saveImageLayoutOrder';
 | 
			
		||||
import { Button } from '@/components/ui/button';
 | 
			
		||||
import { LayoutImage } from '@/utils/justifyPortfolioImages';
 | 
			
		||||
import {
 | 
			
		||||
  closestCorners,
 | 
			
		||||
  DndContext,
 | 
			
		||||
  DragEndEvent,
 | 
			
		||||
  DragOverlay,
 | 
			
		||||
  DragStartEvent,
 | 
			
		||||
  KeyboardSensor,
 | 
			
		||||
  PointerSensor,
 | 
			
		||||
  useSensor,
 | 
			
		||||
  useSensors,
 | 
			
		||||
} from '@dnd-kit/core';
 | 
			
		||||
import {
 | 
			
		||||
  arrayMove,
 | 
			
		||||
  rectSortingStrategy,
 | 
			
		||||
  SortableContext,
 | 
			
		||||
  useSortable,
 | 
			
		||||
} from '@dnd-kit/sortable';
 | 
			
		||||
import { CSS } from '@dnd-kit/utilities';
 | 
			
		||||
import clsx from 'clsx';
 | 
			
		||||
import { GripVertical, Trash2Icon } from 'lucide-react';
 | 
			
		||||
import Image from 'next/image';
 | 
			
		||||
import { useState } from 'react';
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  images: LayoutImage[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type GroupKey = 'highlighted' | 'featured' | 'default';
 | 
			
		||||
 | 
			
		||||
export function AdvancedMosaicGallery({ images }: Props) {
 | 
			
		||||
  const [activeId, setActiveId] = useState<string | null>(null);
 | 
			
		||||
  const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor));
 | 
			
		||||
 | 
			
		||||
  const [columns, setColumns] = useState<Record<GroupKey, LayoutImage[]>>(() => {
 | 
			
		||||
    const groups: Record<GroupKey, LayoutImage[]> = {
 | 
			
		||||
      highlighted: [],
 | 
			
		||||
      featured: [],
 | 
			
		||||
      default: [],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    images.forEach((img) => {
 | 
			
		||||
      const group = (img.layoutGroup ?? 'default') as GroupKey;
 | 
			
		||||
      groups[group].push(img);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return groups;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleDragStart = (event: DragStartEvent) => {
 | 
			
		||||
    setActiveId(String(event.active.id));
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDragEnd = async (event: DragEndEvent) => {
 | 
			
		||||
    const { active, over } = event;
 | 
			
		||||
    setActiveId(null);
 | 
			
		||||
 | 
			
		||||
    if (!over || active.id === over.id) return;
 | 
			
		||||
 | 
			
		||||
    const findColumn = (id: string) => {
 | 
			
		||||
      return (Object.keys(columns) as (keyof typeof columns)[]).find((col) =>
 | 
			
		||||
        columns[col].some((img) => img.id === id)
 | 
			
		||||
      );
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const fromCol = findColumn(String(active.id));
 | 
			
		||||
    const toCol = findColumn(String(over.id));
 | 
			
		||||
 | 
			
		||||
    if (!fromCol || !toCol) return;
 | 
			
		||||
 | 
			
		||||
    const activeIndex = columns[fromCol].findIndex((i) => i.id === active.id);
 | 
			
		||||
    const overIndex = columns[toCol].findIndex((i) => i.id === over.id);
 | 
			
		||||
 | 
			
		||||
    if (fromCol === toCol) {
 | 
			
		||||
      const updated = arrayMove(columns[fromCol], activeIndex, overIndex);
 | 
			
		||||
      setColumns((prev) => ({
 | 
			
		||||
        ...prev,
 | 
			
		||||
        [fromCol]: updated,
 | 
			
		||||
      }));
 | 
			
		||||
    } else {
 | 
			
		||||
      const moved = columns[fromCol][activeIndex];
 | 
			
		||||
      const updatedFrom = [...columns[fromCol]];
 | 
			
		||||
      updatedFrom.splice(activeIndex, 1);
 | 
			
		||||
 | 
			
		||||
      const updatedTo = [...columns[toCol]];
 | 
			
		||||
      updatedTo.splice(overIndex, 0, { ...moved, layoutGroup: toCol });
 | 
			
		||||
 | 
			
		||||
      setColumns((prev) => ({
 | 
			
		||||
        ...prev,
 | 
			
		||||
        [fromCol]: updatedFrom,
 | 
			
		||||
        [toCol]: updatedTo,
 | 
			
		||||
      }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await saveImageLayoutOrder({
 | 
			
		||||
      highlighted: columns.highlighted.map((i) => i.id),
 | 
			
		||||
      featured: columns.featured.map((i) => i.id),
 | 
			
		||||
      default: columns.default.map((i) => i.id),
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <DndContext
 | 
			
		||||
      sensors={sensors}
 | 
			
		||||
      collisionDetection={closestCorners}
 | 
			
		||||
      onDragStart={handleDragStart}
 | 
			
		||||
      onDragEnd={handleDragEnd}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="space-y-8">
 | 
			
		||||
        {(['highlighted', 'featured', 'default'] as const).map((group) => (
 | 
			
		||||
          <SortableContext
 | 
			
		||||
            key={group}
 | 
			
		||||
            items={columns[group].map((i) => String(i.id))}
 | 
			
		||||
            strategy={rectSortingStrategy}
 | 
			
		||||
          >
 | 
			
		||||
            <div>
 | 
			
		||||
              <h2 className="text-lg font-semibold capitalize mb-2">{group}</h2>
 | 
			
		||||
              <div className={clsx('flex gap-2 flex-wrap min-h-[80px] border rounded-md p-2')}>
 | 
			
		||||
                {columns[group].map((image) => (
 | 
			
		||||
                  <SortableImageCard key={image.id} image={image} />
 | 
			
		||||
                ))}
 | 
			
		||||
                {columns[group].length === 0 && (
 | 
			
		||||
                  <div className="text-muted-foreground text-sm italic">Drop images here</div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </SortableContext>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <DragOverlay>
 | 
			
		||||
        {activeId ? (
 | 
			
		||||
          <div className="w-28 h-28 bg-white shadow-md flex items-center justify-center text-xs rounded border">
 | 
			
		||||
            Dragging…
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : null}
 | 
			
		||||
      </DragOverlay>
 | 
			
		||||
    </DndContext>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SortableImageCard({ image }: { image: LayoutImage }) {
 | 
			
		||||
  const {
 | 
			
		||||
    attributes,
 | 
			
		||||
    listeners,
 | 
			
		||||
    setNodeRef,
 | 
			
		||||
    transform,
 | 
			
		||||
    transition,
 | 
			
		||||
    isDragging,
 | 
			
		||||
  } = useSortable({ id: image.id });
 | 
			
		||||
 | 
			
		||||
  const style = {
 | 
			
		||||
    transform: CSS.Transform.toString(transform),
 | 
			
		||||
    transition,
 | 
			
		||||
    opacity: isDragging ? 0.5 : 1,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      ref={setNodeRef}
 | 
			
		||||
      style={style}
 | 
			
		||||
      className="relative w-[120px] h-[120px] rounded overflow-hidden border shadow bg-white"
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        {...listeners}
 | 
			
		||||
        {...attributes}
 | 
			
		||||
        className="absolute top-1 left-1 bg-white/70 text-muted-foreground p-1 rounded-full cursor-grab"
 | 
			
		||||
        title="Drag to reorder"
 | 
			
		||||
      >
 | 
			
		||||
        <GripVertical className="w-4 h-4" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <Image
 | 
			
		||||
        src={`/api/image/thumbnail/${image.fileKey}.webp`}
 | 
			
		||||
        alt={image.altText ?? image.name}
 | 
			
		||||
        fill
 | 
			
		||||
        className="object-cover"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="absolute bottom-1 right-1">
 | 
			
		||||
        <Button size="icon" variant="ghost" className="bg-white/70">
 | 
			
		||||
          <Trash2Icon className="w-4 h-4 text-red-500" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,33 +1,33 @@
 | 
			
		||||
"use client"
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
 | 
			
		||||
import { usePathname, useRouter } from "next/navigation";
 | 
			
		||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
 | 
			
		||||
 | 
			
		||||
type FilterBarProps = {
 | 
			
		||||
  types: PortfolioType[];
 | 
			
		||||
  albums: PortfolioAlbum[];
 | 
			
		||||
  years: number[];
 | 
			
		||||
  currentType: string;
 | 
			
		||||
  currentPublished: string;
 | 
			
		||||
  groupBy: "year" | "album";
 | 
			
		||||
  groupId: string;
 | 
			
		||||
  years: number[];
 | 
			
		||||
  albums: PortfolioAlbum[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function FilterBar({
 | 
			
		||||
  types,
 | 
			
		||||
  albums,
 | 
			
		||||
  years,
 | 
			
		||||
  currentType,
 | 
			
		||||
  currentPublished,
 | 
			
		||||
  groupBy,
 | 
			
		||||
  groupId,
 | 
			
		||||
  years,
 | 
			
		||||
  albums
 | 
			
		||||
}: FilterBarProps) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const pathname = usePathname();
 | 
			
		||||
  const searchParams = new URLSearchParams();
 | 
			
		||||
  const searchParams = useSearchParams();
 | 
			
		||||
 | 
			
		||||
  const setFilter = (key: string, value: string) => {
 | 
			
		||||
    const params = new URLSearchParams(window.location.search);
 | 
			
		||||
    const params = new URLSearchParams(searchParams);
 | 
			
		||||
 | 
			
		||||
    if (value !== "all") {
 | 
			
		||||
      params.set(key, value);
 | 
			
		||||
@ -35,6 +35,7 @@ export default function FilterBar({
 | 
			
		||||
      params.delete(key);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Reset groupId when switching groupBy
 | 
			
		||||
    if (key === "groupBy") {
 | 
			
		||||
      params.delete("year");
 | 
			
		||||
      params.delete("album");
 | 
			
		||||
@ -44,9 +45,9 @@ export default function FilterBar({
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-wrap gap-4 border-b pb-4">
 | 
			
		||||
    <div className="flex flex-col gap-6 border-b pb-6">
 | 
			
		||||
      {/* GroupBy Toggle */}
 | 
			
		||||
      <div className="flex gap-2 items-center">
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">Group by:</span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={groupBy === "year"}
 | 
			
		||||
@ -60,9 +61,11 @@ export default function FilterBar({
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Subnavigation */}
 | 
			
		||||
      {/* Subnavigation for Year or Album */}
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">Filter:</span>
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">
 | 
			
		||||
          {groupBy === "year" ? "Year:" : "Album:"}
 | 
			
		||||
        </span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={groupId === "all"}
 | 
			
		||||
          label="All"
 | 
			
		||||
@ -146,12 +149,12 @@ function FilterButton({
 | 
			
		||||
  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"
 | 
			
		||||
      className={`px-3 py-1 rounded text-sm border transition ${active
 | 
			
		||||
          ? "bg-primary text-white border-primary"
 | 
			
		||||
          : "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
 | 
			
		||||
        }`}
 | 
			
		||||
    >
 | 
			
		||||
      {label}
 | 
			
		||||
    </button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										275
									
								
								src/components/portfolio/images/MosaicGallery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								src/components/portfolio/images/MosaicGallery.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,275 @@
 | 
			
		||||
'use client';
 | 
			
		||||
 | 
			
		||||
import { deleteImage } from '@/actions/portfolio/images/deleteImage';
 | 
			
		||||
import { saveImageLayoutOrder } from '@/actions/portfolio/images/saveImageLayoutOrder';
 | 
			
		||||
import { Button } from '@/components/ui/button';
 | 
			
		||||
import { LayoutImage, justifyPortfolioImages } from '@/utils/justifyPortfolioImages';
 | 
			
		||||
import {
 | 
			
		||||
  DndContext,
 | 
			
		||||
  DragEndEvent,
 | 
			
		||||
  PointerSensor,
 | 
			
		||||
  closestCenter,
 | 
			
		||||
  useSensor,
 | 
			
		||||
  useSensors,
 | 
			
		||||
} from '@dnd-kit/core';
 | 
			
		||||
import {
 | 
			
		||||
  SortableContext,
 | 
			
		||||
  useSortable,
 | 
			
		||||
  verticalListSortingStrategy
 | 
			
		||||
} from '@dnd-kit/sortable';
 | 
			
		||||
import { CSS } from '@dnd-kit/utilities';
 | 
			
		||||
import { GripVertical, PencilIcon, RefreshCw, Trash2Icon } from 'lucide-react';
 | 
			
		||||
import Image from 'next/image';
 | 
			
		||||
import { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
type Props = {
 | 
			
		||||
  images: LayoutImage[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function MosaicGallery({ images }: Props) {
 | 
			
		||||
  const [containerWidth, setContainerWidth] = useState(1200);
 | 
			
		||||
  const containerRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
 | 
			
		||||
  const [groups, setGroups] = useState<{
 | 
			
		||||
    highlighted: LayoutImage[];
 | 
			
		||||
    featured: LayoutImage[];
 | 
			
		||||
    default: LayoutImage[];
 | 
			
		||||
  }>({ highlighted: [], featured: [], default: [] });
 | 
			
		||||
 | 
			
		||||
  const [layout, setLayout] = useState<{
 | 
			
		||||
    highlighted: LayoutImage[][];
 | 
			
		||||
    featured: LayoutImage[][];
 | 
			
		||||
    default: LayoutImage[][];
 | 
			
		||||
  }>({ highlighted: [], featured: [], default: [] });
 | 
			
		||||
 | 
			
		||||
  const [overId, setOverId] = useState<string | null>(null);
 | 
			
		||||
 | 
			
		||||
  const sensors = useSensors(useSensor(PointerSensor));
 | 
			
		||||
 | 
			
		||||
  // Resize observer to track container width
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!containerRef.current) return;
 | 
			
		||||
    const observer = new ResizeObserver(([entry]) => {
 | 
			
		||||
      setContainerWidth(entry.contentRect.width);
 | 
			
		||||
    });
 | 
			
		||||
    observer.observe(containerRef.current);
 | 
			
		||||
    return () => observer.disconnect();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  // Split incoming images into layout groups
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const split = {
 | 
			
		||||
      highlighted: [] as LayoutImage[],
 | 
			
		||||
      featured: [] as LayoutImage[],
 | 
			
		||||
      default: [] as LayoutImage[],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    images.forEach((img) => {
 | 
			
		||||
      const group = img.layoutGroup;
 | 
			
		||||
      if (group === 'highlighted') split.highlighted.push(img);
 | 
			
		||||
      else if (group === 'featured') split.featured.push(img);
 | 
			
		||||
      else split.default.push(img);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setGroups(split);
 | 
			
		||||
  }, [images]);
 | 
			
		||||
 | 
			
		||||
  const triggerJustify = useCallback(() => {
 | 
			
		||||
    setLayout({
 | 
			
		||||
      highlighted: justifyPortfolioImages(groups.highlighted, containerWidth, 360, 12),
 | 
			
		||||
      featured: justifyPortfolioImages(groups.featured, containerWidth, 300, 12),
 | 
			
		||||
      default: justifyPortfolioImages(groups.default, containerWidth, 240, 12),
 | 
			
		||||
    });
 | 
			
		||||
  }, [groups, containerWidth]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    triggerJustify();
 | 
			
		||||
  }, [triggerJustify]);
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async (id: string) => {
 | 
			
		||||
    try {
 | 
			
		||||
      await deleteImage(id);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error('Failed to delete image', e);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleDragEnd = async (event: DragEndEvent) => {
 | 
			
		||||
    const { active, over } = event;
 | 
			
		||||
    setOverId(null);
 | 
			
		||||
    if (!over || active.id === over.id) return;
 | 
			
		||||
 | 
			
		||||
    const allItems = [...groups.highlighted, ...groups.featured, ...groups.default];
 | 
			
		||||
    const activeItem = allItems.find((img) => img.id === active.id);
 | 
			
		||||
    const overItem = allItems.find((img) => img.id === over.id);
 | 
			
		||||
    if (!activeItem || !overItem) return;
 | 
			
		||||
 | 
			
		||||
    const groupOf = (id: string) => {
 | 
			
		||||
      if (groups.highlighted.some((img) => img.id === id)) return 'highlighted';
 | 
			
		||||
      if (groups.featured.some((img) => img.id === id)) return 'featured';
 | 
			
		||||
      return 'default';
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const activeId = String(active.id);
 | 
			
		||||
    const overId = String(over.id);
 | 
			
		||||
 | 
			
		||||
    const from = groupOf(activeId);
 | 
			
		||||
    const to = groupOf(overId);
 | 
			
		||||
 | 
			
		||||
    const reorderedFrom = [...groups[from]];
 | 
			
		||||
    const reorderedTo = [...groups[to]];
 | 
			
		||||
 | 
			
		||||
    const oldIndex = reorderedFrom.findIndex((i) => i.id === active.id);
 | 
			
		||||
    const newIndex = reorderedTo.findIndex((i) => i.id === over.id);
 | 
			
		||||
 | 
			
		||||
    if (from === to) {
 | 
			
		||||
      reorderedTo.splice(newIndex, 0, reorderedTo.splice(oldIndex, 1)[0]);
 | 
			
		||||
    } else {
 | 
			
		||||
      const [moved] = reorderedFrom.splice(oldIndex, 1);
 | 
			
		||||
      moved.layoutGroup = to;
 | 
			
		||||
      reorderedTo.splice(newIndex, 0, moved);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const newGroups = {
 | 
			
		||||
      ...groups,
 | 
			
		||||
      [from]: reorderedFrom,
 | 
			
		||||
      [to]: reorderedTo,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    setGroups(newGroups);
 | 
			
		||||
    triggerJustify();
 | 
			
		||||
 | 
			
		||||
    await saveImageLayoutOrder({
 | 
			
		||||
      highlighted: newGroups.highlighted.map((i) => i.id),
 | 
			
		||||
      featured: newGroups.featured.map((i) => i.id),
 | 
			
		||||
      default: newGroups.default.map((i) => i.id),
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const allIds = [
 | 
			
		||||
    ...groups.highlighted,
 | 
			
		||||
    ...groups.featured,
 | 
			
		||||
    ...groups.default,
 | 
			
		||||
  ].map((i) => String(i.id));
 | 
			
		||||
 | 
			
		||||
  const renderGroup = (label: string, group: keyof typeof layout, items: LayoutImage[][]) => (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h3 className="font-semibold text-lg mb-2 capitalize">{label}</h3>
 | 
			
		||||
      {items.length === 0 ? (
 | 
			
		||||
        <div className="h-[120px] flex items-center justify-center border-2 border-dashed border-muted-foreground rounded mb-4">
 | 
			
		||||
          <p className="text-muted-foreground text-sm">Drop images here</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        items.map((row, i) => (
 | 
			
		||||
          <div key={i} className="flex gap-3 mb-3">
 | 
			
		||||
            {row.map((img) => (
 | 
			
		||||
              <div key={img.id} style={{ width: img.width, height: img.height }}>
 | 
			
		||||
                <SortableImageCard
 | 
			
		||||
                  image={img}
 | 
			
		||||
                  onDelete={handleDelete}
 | 
			
		||||
                  isOver={overId === img.id}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        ))
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div ref={containerRef}>
 | 
			
		||||
      {images.length === 0 ? (
 | 
			
		||||
        <p className="text-center text-muted-foreground py-16">No images to display</p>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          <div className="flex justify-end mb-4">
 | 
			
		||||
            <Button variant="outline" onClick={triggerJustify}>
 | 
			
		||||
              <RefreshCw className="w-4 h-4 mr-2" />
 | 
			
		||||
              Recalculate layout
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <DndContext
 | 
			
		||||
            sensors={sensors}
 | 
			
		||||
            collisionDetection={closestCenter}
 | 
			
		||||
            onDragOver={(event) => {
 | 
			
		||||
              if (event.over?.id) setOverId(String(event.over.id));
 | 
			
		||||
            }}
 | 
			
		||||
            onDragEnd={handleDragEnd}
 | 
			
		||||
          >
 | 
			
		||||
            <SortableContext items={allIds} strategy={verticalListSortingStrategy}>
 | 
			
		||||
              <div className="space-y-10">
 | 
			
		||||
                {renderGroup('highlighted', 'highlighted', layout.highlighted)}
 | 
			
		||||
                {renderGroup('featured', 'featured', layout.featured)}
 | 
			
		||||
                {renderGroup('default', 'default', layout.default)}
 | 
			
		||||
              </div>
 | 
			
		||||
            </SortableContext>
 | 
			
		||||
          </DndContext>
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SortableImageCard({
 | 
			
		||||
  image,
 | 
			
		||||
  onDelete,
 | 
			
		||||
  isOver,
 | 
			
		||||
}: {
 | 
			
		||||
  image: LayoutImage;
 | 
			
		||||
  onDelete?: (id: string) => void;
 | 
			
		||||
  isOver?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
 | 
			
		||||
    id: image.id,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const style = {
 | 
			
		||||
    transform: CSS.Transform.toString(transform),
 | 
			
		||||
    transition,
 | 
			
		||||
    width: image.width,
 | 
			
		||||
    height: image.height,
 | 
			
		||||
    outline: isOver ? '3px solid #3b82f6' : undefined,
 | 
			
		||||
    outlineOffset: isOver ? '2px' : undefined,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      ref={setNodeRef}
 | 
			
		||||
      style={style}
 | 
			
		||||
      className="relative rounded overflow-hidden border shadow"
 | 
			
		||||
    >
 | 
			
		||||
      <div
 | 
			
		||||
        {...listeners}
 | 
			
		||||
        {...attributes}
 | 
			
		||||
        className="absolute top-1 left-1 bg-white/70 text-muted-foreground p-1 rounded-full cursor-grab"
 | 
			
		||||
        title="Drag to reorder"
 | 
			
		||||
      >
 | 
			
		||||
        <GripVertical className="w-4 h-4" />
 | 
			
		||||
      </div>
 | 
			
		||||
      <Image
 | 
			
		||||
        src={`/api/image/thumbnail/${image.fileKey}.webp`}
 | 
			
		||||
        alt={image.altText ?? image.name}
 | 
			
		||||
        width={image.width}
 | 
			
		||||
        height={image.height}
 | 
			
		||||
        className="object-cover w-full h-full"
 | 
			
		||||
      />
 | 
			
		||||
      <div className="absolute bottom-1 right-1 flex gap-1">
 | 
			
		||||
        <button
 | 
			
		||||
          title="Edit"
 | 
			
		||||
          className="bg-white/80 text-muted-foreground hover:bg-white p-1 rounded-full"
 | 
			
		||||
        >
 | 
			
		||||
          <PencilIcon className="w-4 h-4" />
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          title="Delete"
 | 
			
		||||
          onClick={() => onDelete?.(image.id)}
 | 
			
		||||
          className="bg-white/80 text-destructive hover:bg-white p-1 rounded-full"
 | 
			
		||||
        >
 | 
			
		||||
          <Trash2Icon className="w-4 h-4" />
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,45 +0,0 @@
 | 
			
		||||
import EditImageForm from "@/components/portfolio/images/EditImageForm";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
 | 
			
		||||
  const { id } = await params;
 | 
			
		||||
  const image = await prisma.portfolioImage.findUnique({
 | 
			
		||||
    where: { id },
 | 
			
		||||
    include: {
 | 
			
		||||
      type: true,
 | 
			
		||||
      metadata: true,
 | 
			
		||||
      categories: true,
 | 
			
		||||
      colors: { include: { color: true } },
 | 
			
		||||
      tags: true,
 | 
			
		||||
      variants: true
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
 | 
			
		||||
  const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
 | 
			
		||||
  const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
 | 
			
		||||
 | 
			
		||||
  if (!image) return <div>Image not found</div>
 | 
			
		||||
 | 
			
		||||
  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} types={types} /> : 'Image not found...'}
 | 
			
		||||
          <div className="mt-6">
 | 
			
		||||
            {image && <DeleteImageButton imageId={image.id} />}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            {image && <ImageVariants variants={image.variants} />}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="space-y-6">
 | 
			
		||||
          <div>
 | 
			
		||||
            {image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								src/schemas/portfolio/albumSchema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/schemas/portfolio/albumSchema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
import { z } from "zod/v4"
 | 
			
		||||
 | 
			
		||||
export const albumSchema = 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 albumSchema = z.infer<typeof albumSchema>
 | 
			
		||||
							
								
								
									
										65
									
								
								src/utils/justifyPortfolioImages.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/utils/justifyPortfolioImages.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,65 @@
 | 
			
		||||
 | 
			
		||||
import { PortfolioImage } from "@/generated/prisma";
 | 
			
		||||
 | 
			
		||||
export type LayoutImage = PortfolioImage & {
 | 
			
		||||
  width: number;
 | 
			
		||||
  height: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function shuffleImages<T>(arr: T[]): T[] {
 | 
			
		||||
  const shuffled = [...arr];
 | 
			
		||||
  for (let i = shuffled.length - 1; i > 0; i--) {
 | 
			
		||||
    const j = Math.floor(Math.random() * (i + 1));
 | 
			
		||||
    [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
 | 
			
		||||
  }
 | 
			
		||||
  return shuffled;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function justifyPortfolioImages(
 | 
			
		||||
  images: LayoutImage[],
 | 
			
		||||
  containerWidth: number,
 | 
			
		||||
  rowHeight: number,
 | 
			
		||||
  gap: number = 4
 | 
			
		||||
): LayoutImage[][] {
 | 
			
		||||
  const shuffledImages = shuffleImages(images);
 | 
			
		||||
 | 
			
		||||
  const rows: LayoutImage[][] = [];
 | 
			
		||||
  let currentRow: LayoutImage[] = [];
 | 
			
		||||
  let currentWidth = 0;
 | 
			
		||||
 | 
			
		||||
  for (const image of shuffledImages) {
 | 
			
		||||
    const scale = rowHeight / image.height;
 | 
			
		||||
    const scaledWidth = image.width * scale;
 | 
			
		||||
 | 
			
		||||
    if (currentWidth + scaledWidth + gap * currentRow.length > containerWidth && currentRow.length > 0) {
 | 
			
		||||
      const totalAspectRatio = currentRow.reduce((sum, img) => sum + img.width / img.height, 0);
 | 
			
		||||
 | 
			
		||||
      const adjustedRow: LayoutImage[] = currentRow.map((img) => {
 | 
			
		||||
        const ratio = img.width / img.height;
 | 
			
		||||
        const width = (containerWidth - gap * (currentRow.length - 1)) * (ratio / totalAspectRatio);
 | 
			
		||||
        return { ...img, width, height: rowHeight };
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      rows.push(adjustedRow);
 | 
			
		||||
      currentRow = [];
 | 
			
		||||
      currentWidth = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    currentRow.push(image);
 | 
			
		||||
    currentWidth += scaledWidth;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (currentRow.length > 0) {
 | 
			
		||||
    const adjustedRow: LayoutImage[] = currentRow.map((img) => {
 | 
			
		||||
      const scale = rowHeight / img.height;
 | 
			
		||||
      return {
 | 
			
		||||
        ...img,
 | 
			
		||||
        width: img.width * scale,
 | 
			
		||||
        height: rowHeight,
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
    rows.push(adjustedRow);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return rows;
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user