Working ImageSortGallery

This commit is contained in:
2025-07-26 12:20:44 +02:00
parent 7a8c495f60
commit 3c0e191cd9
21 changed files with 460 additions and 770 deletions

View File

@ -1,10 +0,0 @@
'use server';
import prisma from "@/lib/prisma";
export async function getLatestTos(): Promise<string | null> {
const tos = await prisma.termsOfService.findFirst({
orderBy: { createdAt: 'desc' },
});
return tos?.markdown ?? null;
}

View File

@ -1,11 +0,0 @@
'use server';
import prisma from "@/lib/prisma";
export async function saveTosAction(markdown: string) {
await prisma.termsOfService.create({
data: {
markdown,
},
});
}

View File

@ -1,19 +0,0 @@
"use server"
import prisma from "@/lib/prisma"
export async function deleteCommissionType(typeId: string) {
await prisma.commissionTypeOption.deleteMany({
where: { typeId },
})
await prisma.commissionTypeExtra.deleteMany({
where: { typeId },
})
await prisma.commissionType.delete({
where: { id: typeId },
})
}

View File

@ -1,81 +0,0 @@
"use server"
import prisma from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({
data: {
name: data.name,
description: "",
},
})
}
export async function createCommissionExtra(data: { name: string }) {
return await prisma.commissionExtra.create({
data: {
name: data.name,
description: "",
},
})
}
export async function createCommissionCustomInput(data: {
name: string
fieldId: string
}) {
return await prisma.commissionCustomInput.create({
data: {
name: data.name,
fieldId: data.fieldId,
},
})
}
export async function createCommissionType(formData: commissionTypeSchema) {
const parsed = commissionTypeSchema.safeParse(formData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const created = await prisma.commissionType.create({
data: {
name: data.name,
description: data.description,
options: {
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price,
pricePercent: opt.pricePercent,
priceRange: opt.priceRange,
sortIndex: index,
})) || [],
},
extras: {
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price,
pricePercent: ext.pricePercent,
priceRange: ext.priceRange,
sortIndex: index,
})) || [],
},
customInputs: {
create: data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } },
label: c.label,
inputType: c.inputType,
required: c.required,
sortIndex: index,
})) || [],
},
},
})
return created
}

View File

@ -1,16 +0,0 @@
"use server"
import prisma from "@/lib/prisma";
export async function updateCommissionTypeSortOrder(
ordered: { id: string; sortIndex: number }[]
) {
const updates = ordered.map(({ id, sortIndex }) =>
prisma.commissionType.update({
where: { id },
data: { sortIndex },
})
)
await Promise.all(updates)
}

View File

@ -1,57 +0,0 @@
"use server"
import prisma from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
import * as z from "zod/v4"
export async function updateCommissionType(
id: string,
rawData: z.infer<typeof commissionTypeSchema>
) {
const data = commissionTypeSchema.parse(rawData)
const updated = await prisma.commissionType.update({
where: { id },
data: {
name: data.name,
description: data.description,
options: {
deleteMany: {},
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price ?? null,
pricePercent: opt.pricePercent ?? null,
priceRange: opt.priceRange ?? null,
sortIndex: index,
})),
},
extras: {
deleteMany: {},
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price ?? null,
pricePercent: ext.pricePercent ?? null,
priceRange: ext.priceRange ?? null,
sortIndex: index,
})),
},
customInputs: {
deleteMany: {},
create: data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } },
label: c.label,
inputType: c.inputType,
required: c.required,
sortIndex: index,
})) || [],
},
},
include: {
options: true,
extras: true,
customInputs: true,
},
})
return updated
}

View File

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

View File

@ -1,28 +0,0 @@
"use server"
import prisma from '@/lib/prisma';
import { artTypeSchema } from '@/schemas/artTypeSchema';
import { z } from 'zod/v4';
export async function updateArtType(id: string,
rawData: z.infer<typeof artTypeSchema>) {
const parsed = artTypeSchema.safeParse(rawData)
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
}
const data = parsed.data
const updated = await prisma.portfolioArtType.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
},
})
return updated
}

View File

@ -1,15 +0,0 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateArtTypeSortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.portfolioArtType.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -1,67 +0,0 @@
"use server";
import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
export async function deleteImage(imageId: string) {
const image = await prisma.portfolioImage.findUnique({
where: { id: imageId },
include: {
variants: true,
colors: true,
metadata: true,
tags: true,
categories: true,
},
});
if (!image) throw new Error("Image not found");
// Delete S3 objects
for (const variant of image.variants) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: "gaertan",
Key: variant.s3Key,
})
);
} catch (err) {
console.warn("Failed to delete S3 object: " + variant.s3Key + ". " + err);
}
}
// Step 1: Delete join entries
await prisma.imageColor.deleteMany({ where: { imageId } });
// Colors
for (const color of image.colors) {
const count = await prisma.imageColor.count({
where: { colorId: color.colorId },
});
if (count === 0) {
await prisma.color.delete({ where: { id: color.colorId } });
}
}
// Delete variants
await prisma.imageVariant.deleteMany({ where: { imageId } });
// Delete metadata
await prisma.imageMetadata.deleteMany({ where: { imageId } });
// Clean many-to-many tag/category joins
await prisma.portfolioImage.update({
where: { id: imageId },
data: {
tags: { set: [] },
categories: { set: [] },
},
});
// Finally delete the image
await prisma.portfolioImage.delete({ where: { id: imageId } });
return { success: true };
}

View File

@ -1,66 +0,0 @@
"use server"
import prisma from "@/lib/prisma";
import { VibrantSwatch } from "@/types/VibrantSwatch";
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
import { Vibrant } from "node-vibrant/node";
export async function generateImageColors(imageId: string, fileKey: string, fileType?: string) {
const buffer = await getImageBufferFromS3(fileKey, fileType);
const palette = await Vibrant.from(buffer).getPalette();
const vibrantHexes = Object.entries(palette).map(([key, swatch]) => {
const castSwatch = swatch as VibrantSwatch | null;
const rgb = castSwatch?._rgb;
const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
return { type: key, hex };
});
for (const { type, hex } of vibrantHexes) {
if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
const name = generateColorName(hex);
const color = await prisma.color.upsert({
where: { name },
create: {
name,
type,
hex,
red: r,
green: g,
blue: b,
},
update: {
hex,
red: r,
green: g,
blue: b,
},
});
await prisma.imageColor.upsert({
where: {
imageId_type: {
imageId,
type,
},
},
create: {
imageId,
colorId: color.id,
type,
},
update: {
colorId: color.id,
},
});
}
return await prisma.imageColor.findMany({
where: { imageId },
include: { color: true },
});
}

View File

@ -1,74 +0,0 @@
"use server"
import prisma from "@/lib/prisma";
import { imageSchema } from "@/schemas/imageSchema";
import * as z from "zod/v4";
export async function updateImage(
values: z.infer<typeof imageSchema>,
id: string
) {
const validated = imageSchema.safeParse(values);
if (!validated.success) {
throw new Error("Invalid image data");
}
const {
fileKey,
originalFile,
nsfw,
published,
altText,
description,
fileType,
name,
slug,
type,
fileSize,
creationDate,
tagIds,
categoryIds
} = validated.data;
const updatedImage = await prisma.portfolioImage.update({
where: { id: id },
data: {
fileKey,
originalFile,
nsfw,
published,
altText,
description,
fileType,
name,
slug,
type,
fileSize,
creationDate,
}
});
if (tagIds) {
await prisma.portfolioImage.update({
where: { id: id },
data: {
tags: {
set: tagIds.map(id => ({ id }))
}
}
});
}
if (categoryIds) {
await prisma.portfolioImage.update({
where: { id: id },
data: {
categories: {
set: categoryIds.map(id => ({ id }))
}
}
});
}
return updatedImage
}

View File

@ -1,15 +0,0 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateImageSortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.portfolioImage.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -1,190 +0,0 @@
"use server"
import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { imageUploadSchema } from "@/schemas/imageSchema";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
import { v4 as uuidv4 } from 'uuid';
import * as z from "zod/v4";
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
const imageFile = values.file[0];
if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null;
}
const fileName = imageFile.name;
const fileType = imageFile.type;
const fileSize = imageFile.size;
const lastModified = new Date(imageFile.lastModified);
const fileKey = uuidv4();
const arrayBuffer = await imageFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const realFileType = fileType.split("/")[1];
const originalKey = `original/${fileKey}.${realFileType}`;
const modifiedKey = `modified/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnail/${fileKey}.webp`;
const sharpData = sharp(buffer);
const metadata = await sharpData.metadata();
//--- Original file
await s3.send(
new PutObjectCommand({
Bucket: "gaertan",
Key: originalKey,
Body: buffer,
ContentType: "image/" + metadata.format,
})
);
//--- Modified file
const modifiedBuffer = await sharp(buffer)
.toFormat('webp')
.toBuffer()
const modifiedMetadata = await sharp(modifiedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: "gaertan",
Key: modifiedKey,
Body: modifiedBuffer,
ContentType: "image/" + modifiedMetadata.format,
})
);
//--- Resized file
const { width, height } = modifiedMetadata;
const targetSize = 400;
let resizeOptions;
if (width && height) {
if (height < width) {
resizeOptions = { height: targetSize };
} else {
resizeOptions = { width: targetSize };
}
} else {
resizeOptions = { height: targetSize };
}
const resizedBuffer = await sharp(modifiedBuffer)
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: "gaertan",
Key: resizedKey,
Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format,
})
);
//--- Thumbnail file
// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
const thumbnailTargetSize = 160;
let thumbnailOptions;
if (width && height) {
if (height < width) {
thumbnailOptions = { height: thumbnailTargetSize };
} else {
thumbnailOptions = { width: thumbnailTargetSize };
}
} else {
thumbnailOptions = { height: thumbnailTargetSize };
}
const thumbnailBuffer = await sharp(modifiedBuffer)
.resize({ ...thumbnailOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: "gaertan",
Key: thumbnailKey,
Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format,
})
);
const image = await prisma.portfolioImage.create({
data: {
name: fileName,
fileKey,
originalFile: fileName,
creationDate: lastModified,
fileType: fileType,
fileSize: fileSize
},
});
await prisma.imageMetadata.create({
data: {
imageId: image.id,
format: metadata.format || "unknown",
width: metadata.width || 0,
height: metadata.height || 0,
space: metadata.space || "unknown",
channels: metadata.channels || 0,
depth: metadata.depth || "unknown",
density: metadata.density ?? undefined,
bitsPerSample: metadata.bitsPerSample ?? undefined,
isProgressive: metadata.isProgressive ?? undefined,
isPalette: metadata.isPalette ?? undefined,
hasProfile: metadata.hasProfile ?? undefined,
hasAlpha: metadata.hasAlpha ?? undefined,
autoOrientW: metadata.autoOrient?.width ?? undefined,
autoOrientH: metadata.autoOrient?.height ?? undefined,
},
});
await prisma.imageVariant.createMany({
data: [
{
s3Key: originalKey,
type: "original",
height: metadata.height,
width: metadata.width,
fileExtension: metadata.format,
mimeType: "image/" + metadata.format,
sizeBytes: metadata.size,
imageId: image.id
},
{
s3Key: modifiedKey,
type: "modified",
height: modifiedMetadata.height,
width: modifiedMetadata.width,
fileExtension: modifiedMetadata.format,
mimeType: "image/" + modifiedMetadata.format,
sizeBytes: modifiedMetadata.size,
imageId: image.id
},
{
s3Key: resizedKey,
type: "resized",
height: resizedMetadata.height,
width: resizedMetadata.width,
fileExtension: resizedMetadata.format,
mimeType: "image/" + resizedMetadata.format,
sizeBytes: resizedMetadata.size,
imageId: image.id
},
{
s3Key: thumbnailKey,
type: "thumbnail",
height: thumbnailMetadata.height,
width: thumbnailMetadata.width,
fileExtension: thumbnailMetadata.format,
mimeType: "image/" + thumbnailMetadata.format,
sizeBytes: thumbnailMetadata.size,
imageId: image.id
}
],
});
return image
}

View File

@ -16,14 +16,16 @@ export async function updateImage(
const {
fileKey,
originalFile,
name,
nsfw,
published,
setAsHeader,
altText,
description,
fileType,
name,
fileSize,
month,
year,
creationDate,
typeId,
tagIds,
@ -43,14 +45,16 @@ export async function updateImage(
data: {
fileKey,
originalFile,
name,
nsfw,
published,
setAsHeader,
altText,
description,
fileType,
name,
fileSize,
month,
year,
creationDate,
typeId
}

View File

@ -1,5 +1,5 @@
import { AdvancedMosaicGallery } from "@/components/portfolio/images/AdvancedMosaicGallery";
import FilterBar from "@/components/portfolio/images/FilterBar";
import ImageGallery from "@/components/portfolio/images/ImageGallery";
import { Prisma } from "@/generated/prisma";
import prisma from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
@ -79,7 +79,6 @@ export default async function PortfolioImagesPage({
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image
</Link>
</div>
<FilterBar
types={types}
albums={albums}
@ -90,14 +89,19 @@ export default async function PortfolioImagesPage({
groupId={groupId}
/>
<div className="mt-6">
{images && images.length > 0 ? <AdvancedMosaicGallery
{/* {images && images.length > 0 ? <MosaicGallery
images={images.map((img) => ({
...img,
width: 400,
height: 300,
}))}
/> : <p className="text-muted-foreground italic">No images found.</p>}
/> : <p className="text-muted-foreground italic">No images found.</p>} */}
{images && images.length > 0 ?
<ImageGallery images={images} />
:
<p className="text-muted-foreground italic">No images found.</p>
}
</div>
</div>
</div >
);
}

View File

@ -0,0 +1,73 @@
import ImageSortGallery from "@/components/portfolio/images/ImageSortGallery";
import { Prisma } from "@/generated/prisma";
import prisma from "@/lib/prisma";
export default async function PortfolioImagesSortPage({
searchParams
}: {
searchParams?: {
type?: string;
published?: string;
groupBy?: string;
year?: string;
album?: string;
}
}) {
const {
type = "all",
published = "all",
groupBy = "year",
year,
album,
} = await searchParams ?? {};
const groupMode = groupBy === "album" ? "album" : "year";
const groupId = groupMode === "album" ? album ?? "all" : year ?? "all";
const where: Prisma.PortfolioImageWhereInput = {};
// Filter by type
if (type !== "all") {
where.typeId = type === "none" ? null : type;
}
// Filter by published status
if (published === "published") {
where.published = true;
} else if (published === "unpublished") {
where.published = false;
}
// Filter by group (year or album)
if (groupMode === "year" && groupId !== "all") {
where.year = parseInt(groupId);
} else if (groupMode === "album" && groupId !== "all") {
where.albumId = groupId;
}
const images = await prisma.portfolioImage.findMany(
{
where,
orderBy: [{ sortIndex: 'asc' }],
}
)
return (
<div>
<div className="mt-6">
{/* {images && images.length > 0 ? <MosaicGallery
images={images.map((img) => ({
...img,
width: 400,
height: 300,
}))}
/> : <p className="text-muted-foreground italic">No images found.</p>} */}
{images && images.length > 0 ?
<ImageSortGallery images={images} />
:
<p className="text-muted-foreground italic">No images found.</p>
}
</div>
</div>
);
}

View File

@ -128,7 +128,14 @@ export default function EditImageForm({ image, categories, tags, types }:
<FormItem>
<FormLabel>Creation Month</FormLabel>
<FormControl>
<Input {...field} type="number" />
<Input
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -141,7 +148,14 @@ export default function EditImageForm({ image, categories, tags, types }:
<FormItem>
<FormLabel>Creation Year</FormLabel>
<FormControl>
<Input {...field} type="number" />
<Input
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -1,6 +1,8 @@
"use client";
import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
import { SortAscIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type FilterBarProps = {
@ -25,10 +27,9 @@ export default function FilterBar({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const params = new URLSearchParams(searchParams);
const setFilter = (key: string, value: string) => {
const params = new URLSearchParams(searchParams);
if (value !== "all") {
params.set(key, value);
} else {
@ -44,96 +45,108 @@ export default function FilterBar({
router.push(`${pathname}?${params.toString()}`);
};
const sortHref = `${pathname}/sort?${params.toString()}`;
return (
<div className="flex flex-col gap-6 border-b pb-6">
{/* GroupBy Toggle */}
<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"}
label="Year"
onClick={() => setFilter("groupBy", "year")}
/>
<FilterButton
active={groupBy === "album"}
label="Album"
onClick={() => setFilter("groupBy", "album")}
/>
<div>
<div>
<div className="flex justify-end">
<Link href={sortHref} className="flex gap-2 items-center cursor-pointer bg-secondary hover:bg-secondary/90 text-secondary-foreground px-4 py-2 rounded">
<SortAscIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Sort images
</Link>
</div>
</div>
{/* Subnavigation for Year or Album */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{groupBy === "year" ? "Year:" : "Album:"}
</span>
<FilterButton
active={groupId === "all"}
label="All"
onClick={() => setFilter(groupBy, "all")}
/>
{groupBy === "year" &&
years.map((year) => (
<FilterButton
key={year}
active={groupId === String(year)}
label={String(year)}
onClick={() => setFilter("year", String(year))}
/>
))}
{groupBy === "album" &&
albums.map((album) => (
<FilterButton
key={album.id}
active={groupId === album.id}
label={album.name}
onClick={() => setFilter("album", album.id)}
/>
))}
</div>
{/* Type Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Type:</span>
<FilterButton
active={currentType === "all"}
label="All"
onClick={() => setFilter("type", "all")}
/>
{types.map((type) => (
<div className="flex gap-6 pb-6">
{/* GroupBy Toggle */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Group by:</span>
<FilterButton
key={type.id}
active={currentType === type.id}
label={type.name}
onClick={() => setFilter("type", type.id)}
active={groupBy === "year"}
label="Year"
onClick={() => setFilter("groupBy", "year")}
/>
))}
<FilterButton
active={currentType === "none"}
label="No Type"
onClick={() => setFilter("type", "none")}
/>
</div>
<FilterButton
active={groupBy === "album"}
label="Album"
onClick={() => setFilter("groupBy", "album")}
/>
</div>
{/* Type Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Type:</span>
<FilterButton
active={currentType === "all"}
label="All"
onClick={() => setFilter("type", "all")}
/>
{types.map((type) => (
<FilterButton
key={type.id}
active={currentType === type.id}
label={type.name}
onClick={() => setFilter("type", type.id)}
/>
))}
<FilterButton
active={currentType === "none"}
label="No Type"
onClick={() => setFilter("type", "none")}
/>
</div>
{/* Published Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Status:</span>
<FilterButton
active={currentPublished === "all"}
label="All"
onClick={() => setFilter("published", "all")}
/>
<FilterButton
active={currentPublished === "published"}
label="Published"
onClick={() => setFilter("published", "published")}
/>
<FilterButton
active={currentPublished === "unpublished"}
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
{/* Published Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Status:</span>
<FilterButton
active={currentPublished === "all"}
label="All"
onClick={() => setFilter("published", "all")}
/>
<FilterButton
active={currentPublished === "published"}
label="Published"
onClick={() => setFilter("published", "published")}
/>
<FilterButton
active={currentPublished === "unpublished"}
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
</div>
</div>
<div className="flex gap-6 border-b pb-6">
{/* Subnavigation for Year or Album */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{groupBy === "year" ? "Year:" : "Album:"}
</span>
<FilterButton
active={groupId === "all"}
label="All"
onClick={() => setFilter(groupBy, "all")}
/>
{groupBy === "year" &&
years.map((year) => (
<FilterButton
key={year}
active={groupId === String(year)}
label={String(year)}
onClick={() => setFilter("year", String(year))}
/>
))}
{groupBy === "album" &&
albums.map((album) => (
<FilterButton
key={album.id}
active={groupId === album.id}
label={album.name}
onClick={() => setFilter("album", album.id)}
/>
))}
</div>
</div>
</div>
);
}
@ -150,8 +163,8 @@ function FilterButton({
<button
onClick={onClick}
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"
? "bg-primary text-white border-primary"
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
}`}
>
{label}

View File

@ -0,0 +1,49 @@
"use client"
import { PortfolioImage } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
export default function ImageGallery({ images }: { images: PortfolioImage[] }) {
console.log(images);
return (
<div className="w-full flex flex-col gap-4">
<div
className="flex flex-wrap gap-4"
>
{images.map((image) => (
<div key={image.id} style={{ width: 200, height: 200 }}>
<Link href={`/portfolio/images/${image.id}`}>
<div
className={cn(
"overflow-hidden transition-all duration-100",
"w-full h-full",
"hover:border-2 border-transparent"
)}
style={{
'--tw-border-opacity': 1,
} as React.CSSProperties}
>
<div
className={cn(
"relative w-full h-full"
)}
>
<Image
src={`/api/image/thumbnail/${image.fileKey}.webp`}
alt={image.altText ?? image.name ?? "Image"}
fill
className={cn("object-cover"
)}
loading="lazy"
/>
</div>
</div>
</Link>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,207 @@
"use client"
import { PortfolioImage } from "@/generated/prisma"
import {
closestCenter,
DndContext,
DragEndEvent,
useDroppable,
} from "@dnd-kit/core"
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import Image from "next/image"
import React, { useEffect, useState } from "react"
type LayoutGroup = "highlighted" | "featured" | "default"
type GroupedImages = Record<LayoutGroup, PortfolioImage[]>
export default function ImageSortGallery({ images }: { images: PortfolioImage[] }) {
const [items, setItems] = useState<GroupedImages>({
highlighted: [],
featured: [],
default: [],
})
useEffect(() => {
setItems({
highlighted: images
.filter((img) => img.layoutGroup === "highlighted")
.sort((a, b) => a.sortIndex - b.sortIndex),
featured: images
.filter((img) => img.layoutGroup === "featured")
.sort((a, b) => a.sortIndex - b.sortIndex),
default: images
.filter((img) => !img.layoutGroup || img.layoutGroup === "default")
.sort((a, b) => a.sortIndex - b.sortIndex),
})
}, [images])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Find source group (where the item is coming from)
const sourceGroup = findGroupOfItem(activeId);
if (!sourceGroup) return;
// Determine target group (where the item is going to)
let targetGroup: LayoutGroup;
// Check if we're dropping onto an item (then use its group)
const overGroup = findGroupOfItem(overId);
if (overGroup) {
targetGroup = overGroup;
} else {
// Otherwise, we're dropping onto a zone (use the zone's id)
targetGroup = overId as LayoutGroup;
}
// If dropping onto the same item, do nothing
if (sourceGroup === targetGroup && activeId === overId) return;
// Find the active item
const activeItem = items[sourceGroup].find((i) => i.id === activeId);
if (!activeItem) return;
if (sourceGroup === targetGroup) {
// Intra-group movement
const oldIndex = items[sourceGroup].findIndex((i) => i.id === activeId);
const newIndex = items[targetGroup].findIndex((i) => i.id === overId);
if (oldIndex === -1 || newIndex === -1) return;
setItems((prev) => ({
...prev,
[sourceGroup]: arrayMove(prev[sourceGroup], oldIndex, newIndex),
}));
} else {
// Inter-group movement
setItems((prev) => {
// Remove from source group
const updatedSource = prev[sourceGroup].filter((i) => i.id !== activeId);
// Add to target group at the end (or you could insert at a specific position)
const updatedTarget = [...prev[targetGroup], {
...activeItem,
layoutGroup: targetGroup,
sortIndex: prev[targetGroup].length // Set new sort index
}];
return {
...prev,
[sourceGroup]: updatedSource,
[targetGroup]: updatedTarget,
};
});
}
}
const findGroupOfItem = (id: string): LayoutGroup | undefined => {
for (const group of ['highlighted', 'featured', 'default'] as LayoutGroup[]) {
if (items[group].some((img) => img.id === id)) {
return group;
}
}
return undefined;
};
const savePositions = async () => {
await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(items),
})
alert("Positions saved successfully!")
}
return (
<div className="space-y-6">
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{(["highlighted", "featured", "default"] as LayoutGroup[]).map((group) => (
<div key={group}>
<h2 className="text-xl font-bold capitalize mb-2">{group}</h2>
<SortableContext
items={items[group].map((i) => i.id)}
strategy={rectSortingStrategy}
>
<DroplayoutGroup id={group}>
{items[group].map((item) => (
<DraggableImage key={item.id} id={item.id} fileKey={item.fileKey} />
))}
</DroplayoutGroup>
</SortableContext>
</div>
))}
</DndContext>
<button
onClick={savePositions}
className="mt-4 px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700"
>
Save Positions
</button>
</div>
)
}
function DroplayoutGroup({ id, children }: { id: string; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={`min-h-[200px] border-2 border-dashed rounded p-4 flex flex-wrap gap-4 transition-colors ${isOver ? 'bg-blue-100 border-blue-500' : 'bg-gray-50'
} ${React.Children.count(children) === 0 ? 'items-center justify-center' : ''}`}
>
{React.Children.count(children) === 0 ? (
<p className="text-gray-400">Drop images here</p>
) : (
children
)}
</div>
);
}
function DraggableImage({ id, fileKey }: { id: string; fileKey: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="w-[100px] h-[100px] border rounded overflow-hidden"
>
<Image
src={`/api/image/thumbnail/${fileKey}.webp`}
alt=""
className="w-full h-full object-cover"
width={100}
height={100}
draggable={false}
/>
</div>
)
}