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 FilterBar from "@/components/portfolio/images/FilterBar";
|
||||||
import ImageList from "@/components/portfolio/images/ImageList";
|
|
||||||
import { Prisma } from "@/generated/prisma";
|
import { Prisma } from "@/generated/prisma";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { PlusCircleIcon } from "lucide-react";
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
@ -23,7 +23,7 @@ export default async function PortfolioImagesPage({
|
|||||||
groupBy = "year",
|
groupBy = "year",
|
||||||
year,
|
year,
|
||||||
album,
|
album,
|
||||||
} = searchParams ?? {};
|
} = await searchParams ?? {};
|
||||||
|
|
||||||
const groupMode = groupBy === "album" ? "album" : "year";
|
const groupMode = groupBy === "album" ? "album" : "year";
|
||||||
const groupId = groupMode === "album" ? album ?? "all" : year ?? "all";
|
const groupId = groupMode === "album" ? album ?? "all" : year ?? "all";
|
||||||
@ -90,7 +90,13 @@ export default async function PortfolioImagesPage({
|
|||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,29 @@
|
|||||||
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
||||||
import Link from "next/link";
|
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() {
|
export default function TopNav() {
|
||||||
return (
|
return (
|
||||||
<NavigationMenu viewport={false}>
|
<NavigationMenu viewport={false}>
|
||||||
@ -17,42 +40,17 @@ export default function TopNav() {
|
|||||||
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
<NavigationMenuTrigger>Portfolio</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
<ul className="grid w-[300px] gap-4">
|
<ul className="grid w-[300px] gap-4">
|
||||||
<li>
|
{portfolioItems.map((item) => (
|
||||||
<NavigationMenuLink asChild>
|
<li key={item.title}>
|
||||||
<Link href="/portfolio/images">
|
<NavigationMenuLink asChild>
|
||||||
<div className="text-sm leading-none font-medium">Images</div>
|
<Link href={item.href}>
|
||||||
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
<div className="text-sm leading-none font-medium">{item.title}</div>
|
||||||
</p>
|
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
||||||
</Link>
|
</p>
|
||||||
</NavigationMenuLink>
|
</Link>
|
||||||
</li>
|
</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>
|
|
||||||
</ul>
|
</ul>
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</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 { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
type FilterBarProps = {
|
type FilterBarProps = {
|
||||||
types: PortfolioType[];
|
types: PortfolioType[];
|
||||||
|
albums: PortfolioAlbum[];
|
||||||
|
years: number[];
|
||||||
currentType: string;
|
currentType: string;
|
||||||
currentPublished: string;
|
currentPublished: string;
|
||||||
groupBy: "year" | "album";
|
groupBy: "year" | "album";
|
||||||
groupId: string;
|
groupId: string;
|
||||||
years: number[];
|
|
||||||
albums: PortfolioAlbum[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FilterBar({
|
export default function FilterBar({
|
||||||
types,
|
types,
|
||||||
|
albums,
|
||||||
|
years,
|
||||||
currentType,
|
currentType,
|
||||||
currentPublished,
|
currentPublished,
|
||||||
groupBy,
|
groupBy,
|
||||||
groupId,
|
groupId,
|
||||||
years,
|
|
||||||
albums
|
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const setFilter = (key: string, value: string) => {
|
const setFilter = (key: string, value: string) => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
if (value !== "all") {
|
if (value !== "all") {
|
||||||
params.set(key, value);
|
params.set(key, value);
|
||||||
@ -35,6 +35,7 @@ export default function FilterBar({
|
|||||||
params.delete(key);
|
params.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset groupId when switching groupBy
|
||||||
if (key === "groupBy") {
|
if (key === "groupBy") {
|
||||||
params.delete("year");
|
params.delete("year");
|
||||||
params.delete("album");
|
params.delete("album");
|
||||||
@ -44,9 +45,9 @@ export default function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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>
|
<span className="text-sm font-medium text-muted-foreground">Group by:</span>
|
||||||
<FilterButton
|
<FilterButton
|
||||||
active={groupBy === "year"}
|
active={groupBy === "year"}
|
||||||
@ -60,9 +61,11 @@ export default function FilterBar({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subnavigation */}
|
{/* Subnavigation for Year or Album */}
|
||||||
<div className="flex gap-2 items-center flex-wrap">
|
<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
|
<FilterButton
|
||||||
active={groupId === "all"}
|
active={groupId === "all"}
|
||||||
label="All"
|
label="All"
|
||||||
@ -146,12 +149,12 @@ function FilterButton({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`px-3 py-1 rounded text-sm border ${active
|
className={`px-3 py-1 rounded text-sm border transition ${active
|
||||||
? "bg-primary text-white border-primary"
|
? "bg-primary text-white border-primary"
|
||||||
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
|
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</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