Add image handling

This commit is contained in:
2025-07-12 22:08:55 +02:00
parent bc161fc29f
commit b2c77ec9e0
33 changed files with 5357 additions and 110 deletions

2725
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,14 @@
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.844.0",
"@aws-sdk/s3-request-presigner": "^3.844.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@material/material-color-utilities": "^0.3.0",
"@platejs/basic-nodes": "^49.0.0",
"@platejs/code-block": "^49.0.0",
"@platejs/indent": "^49.0.0",
@ -40,20 +43,25 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lowlight": "^3.3.0",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6",
"node-vibrant": "^4.0.3",
"platejs": "^49.1.5",
"react": "^19.0.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.59.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sharp": "^0.34.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar-hide": "^4.0.0",
"uuid": "^11.1.0",
"zod": "^3.25.73"
},
"devDependencies": {

View File

@ -0,0 +1,197 @@
-- CreateTable
CREATE TABLE "CommissionRequest" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"customerName" TEXT NOT NULL,
"customerEmail" TEXT NOT NULL,
"message" TEXT NOT NULL,
"optionId" TEXT,
"typeId" TEXT,
CONSTRAINT "CommissionRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PortfolioImage" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"fileKey" TEXT NOT NULL,
"originalFile" TEXT NOT NULL,
"nsfw" BOOLEAN NOT NULL DEFAULT false,
"published" BOOLEAN NOT NULL DEFAULT false,
"altText" TEXT,
"description" TEXT,
"fileType" TEXT,
"name" TEXT,
"slug" TEXT,
"type" TEXT,
"creationDate" TIMESTAMP(3),
CONSTRAINT "PortfolioImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PortfolioCategory" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT,
"slug" TEXT,
CONSTRAINT "PortfolioCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PortfolioTag" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT,
"slug" TEXT,
CONSTRAINT "PortfolioTag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Color" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"hex" TEXT,
"blue" INTEGER,
"green" INTEGER,
"red" INTEGER,
CONSTRAINT "Color_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImageColor" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"imageId" TEXT NOT NULL,
"colorId" TEXT NOT NULL,
"type" TEXT NOT NULL,
CONSTRAINT "ImageColor_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImageMetadata" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"imageId" TEXT NOT NULL,
"depth" TEXT NOT NULL,
"format" TEXT NOT NULL,
"space" TEXT NOT NULL,
"channels" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"width" INTEGER NOT NULL,
"autoOrientH" INTEGER,
"autoOrientW" INTEGER,
"bitsPerSample" INTEGER,
"density" INTEGER,
"hasAlpha" BOOLEAN,
"hasProfile" BOOLEAN,
"isPalette" BOOLEAN,
"isProgressive" BOOLEAN,
CONSTRAINT "ImageMetadata_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImageVariant" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"imageId" TEXT NOT NULL,
"s3Key" TEXT NOT NULL,
"type" TEXT NOT NULL,
"height" INTEGER NOT NULL,
"width" INTEGER NOT NULL,
"fileExtension" TEXT,
"mimeType" TEXT,
"url" TEXT,
"sizeBytes" INTEGER,
CONSTRAINT "ImageVariant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_PortfolioImageToPortfolioTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PortfolioImageToPortfolioTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_PortfolioCategoryToPortfolioImage" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PortfolioCategoryToPortfolioImage_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioImage_fileKey_key" ON "PortfolioImage"("fileKey");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioImage_originalFile_key" ON "PortfolioImage"("originalFile");
-- CreateIndex
CREATE UNIQUE INDEX "Color_name_key" ON "Color"("name");
-- CreateIndex
CREATE UNIQUE INDEX "ImageColor_imageId_type_key" ON "ImageColor"("imageId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "ImageMetadata_imageId_key" ON "ImageMetadata"("imageId");
-- CreateIndex
CREATE UNIQUE INDEX "ImageVariant_imageId_type_key" ON "ImageVariant"("imageId", "type");
-- CreateIndex
CREATE INDEX "_PortfolioImageToPortfolioTag_B_index" ON "_PortfolioImageToPortfolioTag"("B");
-- CreateIndex
CREATE INDEX "_PortfolioCategoryToPortfolioImage_B_index" ON "_PortfolioCategoryToPortfolioImage"("B");
-- AddForeignKey
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_colorId_fkey" FOREIGN KEY ("colorId") REFERENCES "Color"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageMetadata" ADD CONSTRAINT "ImageMetadata_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PortfolioImage" ADD COLUMN "fileSize" INTEGER;

View File

@ -27,6 +27,7 @@ model CommissionType {
options CommissionTypeOption[]
extras CommissionTypeExtra[]
customInputs CommissionTypeCustomInput[]
requests CommissionRequest[]
}
model CommissionOption {
@ -39,7 +40,8 @@ model CommissionOption {
description String?
types CommissionTypeOption[]
types CommissionTypeOption[]
requests CommissionRequest[]
}
model CommissionExtra {
@ -132,3 +134,151 @@ model TermsOfService {
version Int @default(autoincrement())
markdown String
}
model CommissionRequest {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customerName String
customerEmail String
message String
optionId String?
typeId String?
option CommissionOption? @relation(fields: [optionId], references: [id])
type CommissionType? @relation(fields: [typeId], references: [id])
}
model PortfolioImage {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
fileKey String @unique
originalFile String @unique
nsfw Boolean @default(false)
published Boolean @default(false)
altText String?
description String?
fileType String?
name String?
slug String?
type String?
fileSize Int?
creationDate DateTime?
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioCategory {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String?
description String?
images PortfolioImage[]
}
model PortfolioTag {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String?
description String?
images PortfolioImage[]
}
model Color {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
type String
hex String?
blue Int?
green Int?
red Int?
images ImageColor[]
}
model ImageColor {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String
colorId String
type String
image PortfolioImage @relation(fields: [imageId], references: [id])
color Color @relation(fields: [colorId], references: [id])
@@unique([imageId, type])
}
model ImageMetadata {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String @unique
depth String
format String
space String
channels Int
height Int
width Int
autoOrientH Int?
autoOrientW Int?
bitsPerSample Int?
density Int?
hasAlpha Boolean?
hasProfile Boolean?
isPalette Boolean?
isProgressive Boolean?
image PortfolioImage @relation(fields: [imageId], references: [id])
}
model ImageVariant {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String
s3Key String
type String
height Int
width Int
fileExtension String?
mimeType String?
url String?
sizeBytes Int?
image PortfolioImage @relation(fields: [imageId], references: [id])
@@unique([imageId, type])
}

View File

@ -0,0 +1,67 @@
"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

@ -0,0 +1,66 @@
"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

@ -0,0 +1,74 @@
"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

@ -0,0 +1,15 @@
'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

@ -0,0 +1,190 @@
"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

@ -0,0 +1,33 @@
import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
export async function GET(req: Request, { params }: { params: { key: string[] } }) {
const { key } = await params;
const s3Key = key.join("/");
try {
const command = new GetObjectCommand({
Bucket: "gaertan",
Key: s3Key,
});
const response = await s3.send(command);
if (!response.Body) {
return new Response("No body", { status: 500 });
}
const contentType = response.ContentType ?? "application/octet-stream";
return new Response(response.Body as ReadableStream, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
"Content-Disposition": "inline", // use 'attachment' to force download
},
});
} catch (err) {
console.log(err)
return new Response("Image not found", { status: 404 });
}
}

View File

@ -0,0 +1,45 @@
import DeleteImageButton from "@/components/portfolio/edit/DeleteImageButton";
import EditImageForm from "@/components/portfolio/edit/EditImageForm";
import ImageColors from "@/components/portfolio/edit/ImageColors";
import ImageVariants from "@/components/portfolio/edit/ImageVariants";
import prisma from "@/lib/prisma";
export default async function PortfolioEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const image = await prisma.portfolioImage.findUnique({
where: { id },
include: {
metadata: true,
categories: true,
colors: { include: { color: true } },
tags: true,
variants: true
}
});
const categories = await prisma.portfolioCategory.findMany({ orderBy: { name: "asc" } });
const tags = await prisma.portfolioTag.findMany({ orderBy: { name: "asc" } });
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} /> : '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>
);
}

View File

@ -0,0 +1,28 @@
import ListImages from "@/components/portfolio/ListImages";
import prisma from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function PortfolioPage() {
const images = await prisma.portfolioImage.findMany(
{
orderBy: [
{ sortIndex: 'asc' },
{ creationDate: 'desc' },
{ name: 'asc' }]
}
);
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Images</h1>
<Link href="/portfolio/upload" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image
</Link>
</div>
{images.length > 0 ? <ListImages images={images} /> : <p className="text-muted-foreground italic">No images found.</p>}
</div>
);
}

View File

@ -0,0 +1,10 @@
import UploadImageForm from "@/components/portfolio/upload/UploadImageForm";
export default function PortfolioUploadPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-4">Upload image</h1>
<UploadImageForm />
</div>
);
}

View File

@ -12,6 +12,11 @@ export default function TopNav() {
<Link href="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/portfolio">Portfolio</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/items/commissions/types">CommissionTypes</Link>

View File

@ -0,0 +1,61 @@
"use client"
import { updateImageSortOrder } from "@/actions/portfolio/updateImageSortOrder";
import { PortfolioImage } from "@/generated/prisma";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
import { SortableImage } from "../sort/SortableImage";
import { SortableList } from "../sort/SortableList";
export default function ListImages({ images }: { images: PortfolioImage[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = images.map(image => ({
id: image.id,
sortIndex: image.sortIndex,
label: image.name || "",
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateImageSortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateImageSortOrder(items);
};
if (!isMounted) return null;
return (
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by name"
renderItem={(item) => {
const image = images.find(g => g.id === item.id)!;
return (
<SortableImage
id={image.id}
item={{
id: image.id,
name: image.name || "",
fileKey: image.fileKey,
altText: image.altText || image.name || "",
published: image.published || false,
creationDate: image.creationDate || undefined
}}
/>
);
}}
/>
);
}

View File

@ -0,0 +1,26 @@
"use client"
import { deleteImage } from "@/actions/portfolio/edit/deleteImage";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
export default function DeleteImageButton({ imageId }: { imageId: string }) {
const router = useRouter();
async function handleDelete() {
if (confirm("Are you sure you want to delete this image? This action is irreversible.")) {
const result = await deleteImage(imageId);
if (result?.success) {
router.push("/portfolio"); // redirect to image list or gallery
} else {
alert("Failed to delete image.");
}
}
}
return (
<Button variant="destructive" onClick={handleDelete}>
Delete Image
</Button>
);
}

View File

@ -0,0 +1,316 @@
"use client"
import { updateImage } from "@/actions/portfolio/edit/updateImage";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
type ImageWithItems = PortfolioImage & {
metadata: ImageMetadata | null,
colors: (
ImageColor & {
color: Color
}
)[],
variants: ImageVariant[],
categories: PortfolioCategory[],
tags: PortfolioTag[],
};
export default function EditImageForm({ image, categories, tags }:
{
image: ImageWithItems,
categories: PortfolioCategory[]
tags: PortfolioTag[],
}) {
const router = useRouter();
const form = useForm<z.infer<typeof imageSchema>>({
resolver: zodResolver(imageSchema),
defaultValues: {
fileKey: image.fileKey,
originalFile: image.originalFile,
nsfw: image.nsfw ?? false,
published: image.nsfw ?? false,
altText: image.altText || "",
description: image.description || "",
fileType: image.fileType || "",
name: image.name || "",
slug: image.slug || "",
type: image.type || "",
fileSize: image.fileSize || undefined,
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
categoryIds: image.categories?.map(cat => cat.id) ?? [],
}
})
async function onSubmit(values: z.infer<typeof imageSchema>) {
const updatedImage = await updateImage(values, image.id)
if (updatedImage) {
toast.success("Image updated")
router.push(`/portfolio`)
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="fileKey"
render={({ field }) => (
<FormItem>
<FormLabel>Image Key</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="originalFile"
render={({ field }) => (
<FormItem>
<FormLabel>Original file</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nsfw"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="altText"
render={({ field }) => (
<FormItem>
<FormLabel>Alt Text</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Image name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Image slug</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Image type</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileType"
render={({ field }) => (
<FormItem>
<FormLabel>Filetype</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileSize"
render={({ field }) => (
<FormItem>
<FormLabel>Image fileSize</FormLabel>
<FormControl><Input type="number" {...field} disabled /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="creationDate"
render={({ field }) => (
<FormItem className="flex flex-col gap-1">
<FormLabel>Creation Date</FormLabel>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? format(field.value, "PPP") : "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date)
}}
initialFocus
fromYear={1990}
toYear={2030}
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedOptions = tags
.filter(tag => field.value?.includes(tag.id))
.map(tag => ({ label: tag.name, value: tag.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={tags.map(tag => ({
label: tag.name,
value: tag.id,
}))}
placeholder="Select tags"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
name="categoryIds"
render={({ field }) => {
const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id }));
return (
<FormItem>
<FormLabel>Categories</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={categories.map(cat => ({
label: cat.name,
value: cat.id,
}))}
placeholder="Select categories"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</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 >
);
}

View File

@ -0,0 +1,50 @@
"use client"
import { generateImageColors } from "@/actions/portfolio/edit/generateImageColors";
import { Button } from "@/components/ui/button";
import { Color, ImageColor } from "@/generated/prisma";
import { useState, useTransition } from "react";
import { toast } from "sonner";
type ColorWithItems = ImageColor & {
color: Color
};
export default function ImageColors({ colors: initialColors, imageId, fileKey, fileType }: { colors: ColorWithItems[], imageId: string, fileKey: string, fileType?: string }) {
const [colors, setColors] = useState(initialColors);
const [isPending, startTransition] = useTransition();
const handleGenerate = () => {
startTransition(async () => {
try {
const newColors = await generateImageColors(imageId, fileKey, fileType);
setColors(newColors);
toast.success("Colors extracted successfully");
} catch (err) {
toast.error("Failed to extract colors");
console.error(err);
}
});
};
return (
<>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Image Colors</h2>
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
{isPending ? "Extracting..." : "Generate Palette"}
</Button>
</div >
<div className="flex flex-wrap gap-2">
{colors.map((item) => (
<div
key={`${item.imageId}-${item.type}`}
className="w-10 h-10 rounded"
style={{ backgroundColor: item.color?.hex ?? "#000000" }}
title={`${item.type} ${item.color?.hex}`}
></div>
))}
</div>
</>
);
}

View File

@ -0,0 +1,21 @@
import { ImageVariant } from "@/generated/prisma";
import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image";
export default function ImageVariants({ variants }: { variants: ImageVariant[] }) {
return (
<>
<h2 className="font-semibold text-lg mb-2">Variants</h2>
<div>
{variants.map((variant) => (
<div key={variant.id}>
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
{variant.s3Key && (
<NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" />
)}
</div>
))}
</div>
</>
);
}

View File

@ -0,0 +1,93 @@
"use client"
import { uploadImage } from "@/actions/portfolio/upload/uploadImage";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { imageUploadSchema } from "@/schemas/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
export default function UploadImageForm() {
const router = useRouter();
const [preview, setPreview] = useState<string | null>(null);
const form = useForm<z.infer<typeof imageUploadSchema>>({
resolver: zodResolver(imageUploadSchema),
defaultValues: {
file: undefined
},
})
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
setPreview(reader.result as string);
}
};
reader.readAsDataURL(file);
form.setValue("file", files);
};
async function onSubmit(values: z.infer<typeof imageUploadSchema>) {
const image = await uploadImage(values)
if (image) {
toast.success("Image created")
router.push(`/portfolio/edit/${image.id}`)
}
}
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="file"
render={() => (
<FormItem>
<FormLabel>Choose image to upload</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
onChange={(e) => onFileChange(e)}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div>
</form>
</Form>
<div className="flex justify-center p-4">
{
preview ?
<Image
src={preview}
alt="Preview"
width={200}
height={200}
/>
:
null
}
</div>
</>
);
}

View File

@ -0,0 +1,88 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from 'clsx';
import { CheckCircle, Circle, GripVertical } from 'lucide-react';
import NextImage from 'next/image';
import Link from 'next/link';
type SortableCardItemProps = {
id: string;
item: {
id: string;
name: string;
fileKey: string;
altText: string;
published?: boolean;
creationDate?: Date | string;
};
};
export function SortableImage({ id, item }: SortableCardItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const href = `/portfolio/edit/${item.id}`;
let dateDisplay = null;
if (item.creationDate instanceof Date) {
dateDisplay = item.creationDate.toLocaleDateString('de-DE');
} else if (typeof item.creationDate === 'string') {
const parsed = new Date(item.creationDate);
if (!isNaN(parsed.getTime())) {
dateDisplay = parsed.toLocaleDateString('de-DE');
}
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="relative cursor-grab active:cursor-grabbing"
>
<div
{...listeners}
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
</div>
<Link href={href}>
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
<NextImage
src={`/api/image/thumbnail/${item.fileKey}.webp`}
alt={item.altText ? item.altText : "Image"}
fill
className={clsx("object-cover transition duration-300")}
/>
</div>
{/* Content */}
<div className="p-4 text-center">
<h2 className="text-lg font-semibold truncate">{item.name}</h2>
{dateDisplay && (
<p className="text-xs text-muted-foreground mt-1">{dateDisplay}</p>
)}
</div>
{/* Status icon in bottom-left corner */}
<div className="absolute bottom-2 left-2 z-10 bg-white/80 rounded-full p-1">
{item.published ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Circle className="w-4 h-4 text-muted-foreground" />
)}
</div>
</div>
</Link>
</div>
);
}

View File

@ -0,0 +1,103 @@
'use client';
import type { Color, Image, ImageColor } from '@/generated/prisma';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from 'clsx';
import { GripVertical } from 'lucide-react';
import NextImage from 'next/image';
import Link from 'next/link';
type SupportedTypes = 'gallery' | 'album' | 'artist' | 'category' | 'tag';
const pluralMap: Record<SupportedTypes, string> = {
gallery: 'galleries',
album: 'albums',
artist: 'artists',
category: 'categories',
tag: 'tags',
};
type SortableCardItemProps = {
id: string;
item: {
id: string;
name: string;
type: 'gallery' | 'album' | 'artist' | 'category' | 'tag';
coverImage?: (Image & { colors?: (ImageColor & { color: Color })[] }) | null;
count?: number;
textLabel?: string;
};
};
export function SortableCardItem({ id, item }: SortableCardItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const href = `/${pluralMap[item.type]}/edit/${item.id}`;
const isVisualType = item.type === 'gallery' || item.type === 'album';
let countLabel = '';
if (item.count !== undefined) {
if (item.type === 'gallery') {
countLabel = `${item.count} album${item.count !== 1 ? 's' : ''}`;
} else {
countLabel = `${item.count} image${item.count !== 1 ? 's' : ''}`;
}
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="relative cursor-grab active:cursor-grabbing"
>
<div
{...listeners}
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
</div>
<Link href={href}>
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
{isVisualType ? (
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
{item.coverImage?.fileKey ? (
<NextImage
src={`/api/image/thumbnails/${item.coverImage.fileKey}.webp`}
alt={item.coverImage.imageName}
fill
className={clsx("object-cover transition duration-300")}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No cover image
</div>
)}
</div>
) : null}
<div className={clsx("p-4", !isVisualType && "text-center")}>
<h2 className={clsx("text-lg font-semibold truncate", isVisualType && "text-center")}>
{item.name}
</h2>
{countLabel && (
<p className="text-sm text-muted-foreground mt-1">{countLabel}</p>
)}
{item.textLabel && (
<p className="text-sm text-muted-foreground mt-1">{item.textLabel}</p>
)}
</div>
</div>
</Link>
</div>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState } from 'react';
import type { SortableItem as ItemType } from '@/types/SortableItem';
interface Props {
items: ItemType[];
onReorder: (items: ItemType[]) => void;
onSortDefault?: () => void;
defaultSortLabel?: string;
renderItem: (item: ItemType) => React.ReactNode;
}
export function SortableList({
items,
onReorder,
onSortDefault,
defaultSortLabel = 'Sort by Name',
renderItem,
}: Props) {
const [localItems, setLocalItems] = useState(items);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localItems.findIndex(item => item.id === active.id);
const newIndex = localItems.findIndex(item => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(localItems, oldIndex, newIndex).map((item, index) => ({
...item,
sortIndex: index * 10,
}));
setLocalItems(reordered);
onReorder(reordered);
};
return (
<div className="space-y-4">
{onSortDefault && (
<button
onClick={onSortDefault}
className="px-4 py-2 text-sm font-medium bg-gray-200 rounded hover:bg-gray-300 text-primary-foreground"
>
{defaultSortLabel}
</button>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={localItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{localItems.map(item => (
<div key={item.id}>{renderItem(item)}</div>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@ -0,0 +1,608 @@
'use client';
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
import { X } from 'lucide-react';
import * as React from 'react';
import { forwardRef, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
import { cn } from '@/lib/utils';
export interface Option {
value: string;
label: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
/**
* sync search. This search will not showing loadingIndicator.
* The rest props are the same as async search.
* i.e.: creatable, groupBy, delay.
**/
onSearchSync?: (value: string) => Option[];
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
'value' | 'placeholder' | 'disabled'
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
focus: () => void;
reset: () => void;
}
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
'': options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || '';
if (!groupOption[key]) {
groupOption[key] = [];
}
groupOption[key].push(option);
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn('py-6 text-center text-sm', className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = 'CommandEmpty';
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
onSearchSync,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
hideClearAllButton = false,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [onScrollbar, setOnScrollbar] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState('');
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef?.current?.focus(),
reset: () => setSelected([]),
}),
[selected],
);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setOpen(false);
inputRef.current.blur();
}
};
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (input.value === '' && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If there is a last item and it is not fixed, we can remove it.
if (lastSelectOption && !lastSelectOption.fixed) {
handleUnselect(lastSelectOption);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
};
}, [open]);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
/** sync search */
const doSearchSync = () => {
const res = onSearchSync?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
};
const exec = async () => {
if (!onSearchSync || !open) return;
if (triggerSearchOnFocus) {
doSearchSync();
}
if (debouncedSearchTerm) {
doSearchSync();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
useEffect(() => {
/** async search */
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
ref={dropdownRef}
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
shouldFilter={
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
{
'px-3 py-2': selected.length !== 0,
'cursor-text': !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
>
<div className="relative flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label}
<button
type="button"
className={cn(
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
(disabled || option.fixed) && 'hidden',
)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
if (!onScrollbar) {
setOpen(false);
}
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
inputProps?.onFocus?.(event);
}}
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
className={cn(
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
{
'w-full': hidePlaceholderWhenSelected,
'px-3 py-2': selected.length === 0,
'ml-1': selected.length !== 0,
},
inputProps?.className,
)}
/>
<button
type="button"
onClick={() => {
setSelected(selected.filter((s) => s.fixed));
onChange?.(selected.filter((s) => s.fixed));
}}
className={cn(
'absolute ltr:right-0 rtl:left-0 h-6 w-6 p-0',
(hideClearAllButton ||
disabled ||
selected.length < 1 ||
selected.filter((s) => s.fixed).length === selected.length) &&
'hidden',
)}
>
<X />
</button>
</div>
</div>
<div className="relative">
{open && (
<CommandList
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
onMouseLeave={() => {
setOnScrollbar(false);
}}
onMouseEnter={() => {
setOnScrollbar(true);
}}
onMouseUp={() => {
inputRef?.current?.focus();
}}
>
{isLoading ? (
<>{loadingIndicator}</>
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable && 'cursor-default text-muted-foreground',
)}
>
{option.label}
</CommandItem>
);
})}
</>
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = 'MultipleSelector';
export default MultipleSelector;

21
src/lib/s3.ts Normal file
View File

@ -0,0 +1,21 @@
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const s3 = new S3Client({
region: "us-east-1",
endpoint: "http://10.0.20.11:9010",
forcePathStyle: true,
credentials: {
accessKeyId: "fellies",
secretAccessKey: "XCJ7spqxWZhVn8tkYnfVBFbz2cRKYxPAfeQeIdPRp1",
},
});
export async function getSignedImageUrl(key: string, expiresInSec = 3600) {
const command = new GetObjectCommand({
Bucket: "gaertan",
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: expiresInSec });
}

View File

@ -0,0 +1,28 @@
import * as z from "zod/v4";
export const imageUploadSchema = z.object({
file: z
.custom<FileList>()
.refine((files) => files instanceof FileList && files.length > 0, {
message: "Image file is required",
}),
})
export const imageSchema = z.object({
fileKey: z.string().min(1, "File key is required"),
originalFile: z.string().min(1, "Original file is required"),
nsfw: z.boolean(),
published: z.boolean(),
altText: z.string().optional(),
description: z.string().optional(),
fileType: z.string().optional(),
name: z.string().optional(),
slug: z.string().optional(),
type: z.string().optional(),
fileSize: z.number().optional(),
creationDate: z.date().optional(),
categoryIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
})

View File

@ -0,0 +1,6 @@
export interface SortableItem {
id: string;
sortIndex: number;
label: string; // e.g., name, displayName, or handle
secondary?: string | boolean; // optional (e.g. isPrimary)
}

View File

@ -0,0 +1,4 @@
export type VibrantSwatch = {
_rgb: [number, number, number];
hex?: string;
};

View File

@ -0,0 +1,9 @@
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
const gb = mb / 1024;
return `${gb.toFixed(2)} GB`;
}

View File

@ -0,0 +1,22 @@
import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "stream";
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
const type = fileType ? fileType.split("/")[1] : "webp";
const command = new GetObjectCommand({
Bucket: "gaertan",
Key: `original/${fileKey}.${type}`,
});
const response = await s3.send(command);
const stream = response.Body as Readable;
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

96
src/utils/uploadHelper.ts Normal file
View File

@ -0,0 +1,96 @@
import crypto from 'crypto';
// export function generatePaletteName(tones: Tone[]): string {
// const hexString = tones.map(t => t.hex.toLowerCase()).join('');
// const hash = crypto.createHash('sha256').update(hexString).digest('hex');
// return `palette-${hash.slice(0, 8)}`;
// }
export function generateColorName(hex: string): string {
const hash = crypto.createHash("sha256").update(hex.toLowerCase()).digest("hex");
return `color-${hash.slice(0, 8)}`;
}
// export function generateExtractColorName(hex: string, hue?: number, sat?: number, area?: number): string {
// const data = `${hex.toLowerCase()}-${hue ?? 0}-${sat ?? 0}-${area ?? 0}`;
// const hash = crypto.createHash("sha256").update(data).digest("hex");
// return `extract-${hash.slice(0, 8)}`;
// }
export function rgbToHex(rgb: number[]): string {
return `#${rgb
.map((val) => Math.round(val).toString(16).padStart(2, "0"))
.join("")}`;
}
export function argbToHex(argb: number): string {
return `#${argb.toString(16).slice(2).padStart(6, "0")}`;
}
// export function extractPaletteTones(
// palette: TonalPalette, tones: number[] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
// ) {
// return tones.map((t) => ({
// tone: t,
// hex: argbToHex(palette.tone(t)),
// }));
// }
// export async function upsertPalettes(tones: Tone[], imageId: string, type: string) {
// const paletteName = generatePaletteName(tones);
// const existingPalette = await prisma.colorPalette.findFirst({
// where: { name: paletteName },
// include: { items: true },
// });
// //
// const palette = existingPalette ?? await prisma.colorPalette.create({
// data: {
// name: paletteName,
// items: {
// create: tones.map(tone => ({
// tone: tone.tone,
// hex: tone.hex,
// }))
// }
// }
// });
// await prisma.imagePalette.upsert({
// where: {
// imageId_type: {
// imageId,
// type,
// }
// },
// update: {
// paletteId: palette.id
// },
// create: {
// imageId,
// paletteId: palette.id,
// type,
// }
// });
// // const newPalette = await prisma.colorPalette.create({
// // data: {
// // name: paletteName,
// // type: type,
// // items: {
// // create: tones.map(t => ({
// // tone: t.tone,
// // hex: t.hex,
// // })),
// // },
// // images: {
// // connect: { id: imageId },
// // },
// // },
// // include: { items: true },
// // });
// return palette;
// }