Working ImageSortGallery
This commit is contained in:
@ -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;
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function saveTosAction(markdown: string) {
|
||||
await prisma.termsOfService.create({
|
||||
data: {
|
||||
markdown,
|
||||
},
|
||||
});
|
||||
}
|
@ -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 },
|
||||
})
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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 },
|
||||
});
|
||||
}
|
@ -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
|
||||
}
|
@ -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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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>}
|
||||
</div>
|
||||
/> : <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 >
|
||||
);
|
||||
}
|
73
src/app/portfolio/images/sort/page.tsx
Normal file
73
src/app/portfolio/images/sort/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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 setFilter = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
if (value !== "all") {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
@ -44,8 +45,18 @@ 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">
|
||||
<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>
|
||||
<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>
|
||||
@ -60,37 +71,6 @@ export default function FilterBar({
|
||||
onClick={() => setFilter("groupBy", "album")}
|
||||
/>
|
||||
</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>
|
||||
@ -134,6 +114,39 @@ export default function FilterBar({
|
||||
/>
|
||||
</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>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
49
src/components/portfolio/images/ImageGallery.tsx
Normal file
49
src/components/portfolio/images/ImageGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
207
src/components/portfolio/images/ImageSortGallery.tsx
Normal file
207
src/components/portfolio/images/ImageSortGallery.tsx
Normal 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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user