Refactor images

This commit is contained in:
2025-07-20 12:49:47 +02:00
parent f3c648e854
commit 312b2c2f94
43 changed files with 2486 additions and 177 deletions

View File

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "PORT=3001 NODE_OPTIONS='--max-old-space-size=4096' next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"

View File

@ -0,0 +1,371 @@
-- 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,
"name" TEXT NOT NULL,
"nsfw" BOOLEAN NOT NULL DEFAULT false,
"published" BOOLEAN NOT NULL DEFAULT true,
"setAsHeader" BOOLEAN NOT NULL DEFAULT false,
"altText" TEXT,
"description" TEXT,
"fileType" TEXT,
"layoutGroup" TEXT,
"layoutOrder" INTEGER,
"month" INTEGER,
"year" INTEGER,
"creationDate" TIMESTAMP(3),
"typeId" TEXT,
CONSTRAINT "PortfolioImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PortfolioType" (
"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 NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "PortfolioType_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 NOT NULL,
"slug" TEXT NOT NULL,
"description" 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 NOT NULL,
"slug" TEXT NOT NULL,
"description" 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 "CommissionType" (
"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 NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionType_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionOption" (
"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 NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionExtra" (
"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 NOT NULL,
"description" TEXT,
CONSTRAINT "CommissionExtra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomInput" (
"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 NOT NULL,
"fieldId" TEXT NOT NULL,
CONSTRAINT "CommissionCustomInput_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeOption" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"optionId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionTypeOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeExtra" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"extraId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionTypeExtra_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionTypeCustomInput" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"typeId" TEXT NOT NULL,
"customInputId" TEXT NOT NULL,
"inputType" TEXT NOT NULL,
"label" TEXT NOT NULL,
"required" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "CommissionTypeCustomInput_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TermsOfService" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"version" SERIAL NOT NULL,
"markdown" TEXT NOT NULL,
CONSTRAINT "TermsOfService_pkey" PRIMARY KEY ("id")
);
-- 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 "_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 "PortfolioType_name_key" ON "PortfolioType"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioType_slug_key" ON "PortfolioType"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioCategory_name_key" ON "PortfolioCategory"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioCategory_slug_key" ON "PortfolioCategory"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioTag_name_key" ON "PortfolioTag"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioTag_slug_key" ON "PortfolioTag"("slug");
-- 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 UNIQUE INDEX "CommissionCustomInput_name_key" ON "CommissionCustomInput"("name");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeOption_typeId_optionId_key" ON "CommissionTypeOption"("typeId", "optionId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeExtra_typeId_extraId_key" ON "CommissionTypeExtra"("typeId", "extraId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionTypeCustomInput_typeId_customInputId_key" ON "CommissionTypeCustomInput"("typeId", "customInputId");
-- CreateIndex
CREATE INDEX "_PortfolioImageToPortfolioTag_B_index" ON "_PortfolioImageToPortfolioTag"("B");
-- CreateIndex
CREATE INDEX "_PortfolioCategoryToPortfolioImage_B_index" ON "_PortfolioCategoryToPortfolioImage"("B");
-- AddForeignKey
ALTER TABLE "PortfolioImage" ADD CONSTRAINT "PortfolioImage_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "PortfolioType"("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 "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_customInputId_fkey" FOREIGN KEY ("customInputId") REFERENCES "CommissionCustomInput"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- 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 "_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

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -14,6 +14,165 @@ datasource db {
url = env("DATABASE_URL")
}
// Portfolio
model PortfolioImage {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
fileKey String @unique
originalFile String @unique
name String
nsfw Boolean @default(false)
published Boolean @default(true)
setAsHeader Boolean @default(false)
altText String?
description String?
fileType String?
layoutGroup String?
fileSize Int?
layoutOrder Int?
month Int?
year Int?
creationDate DateTime?
// group String?
// kind String?
// series String?
// slug String?
// fileSize Int?
typeId String?
type PortfolioType? @relation(fields: [typeId], references: [id])
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
description String?
images PortfolioImage[]
}
model PortfolioCategory {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
slug String @unique
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 @unique
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])
}
model CommissionType {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -149,159 +308,3 @@ model CommissionRequest {
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)
setAsHeader Boolean @default(false)
altText String?
description String?
fileType String?
group String?
kind String?
layoutGroup String?
name String?
series String?
slug String?
type String?
fileSize Int?
layoutOrder Int?
month Int?
year Int?
creationDate DateTime?
artTypeId String?
artType PortfolioArtType? @relation(fields: [artTypeId], references: [id])
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioArtType {
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 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

@ -1,7 +1,7 @@
"use server"
import prisma from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissionType"
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({

View File

@ -1,7 +1,7 @@
"use server"
import prisma from "@/lib/prisma"
import { commissionTypeSchema } from "@/schemas/commissionType"
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
import * as z from "zod/v4"
export async function updateCommissionType(

View File

@ -0,0 +1,189 @@
"use server"
import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { imageUploadSchema } from "@/schemas/portfolio/imageSchema";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
import { v4 as uuidv4 } from 'uuid';
import { z } from "zod/v4";
export async function createImage(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 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: realFileType,
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,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,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function sortImages(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.portfolioImage.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -0,0 +1,70 @@
"use server"
import prisma from "@/lib/prisma";
import { imageSchema } from "@/schemas/portfolio/imageSchema";
import { 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,
fileSize,
creationDate,
tagIds,
categoryIds
} = validated.data;
const updatedImage = await prisma.portfolioImage.update({
where: { id: id },
data: {
fileKey,
originalFile,
nsfw,
published,
altText,
description,
fileType,
name,
fileSize,
creationDate,
}
});
if (tagIds) {
await prisma.portfolioImage.update({
where: { id: id },
data: {
tags: {
set: tagIds.map(id => ({ id }))
}
}
});
}
if (categoryIds) {
await prisma.portfolioImage.update({
where: { id: id },
data: {
categories: {
set: categoryIds.map(id => ({ id }))
}
}
});
}
return updatedImage
}

View File

@ -1,5 +1,48 @@
export default function PortfolioImagesEditPage() {
import DeleteImageButton from "@/components/portfolio/images/DeleteImageButton";
import EditImageForm from "@/components/portfolio/images/EditImageForm";
import ImageColors from "@/components/portfolio/images/ImageColors";
import ImageVariants from "@/components/portfolio/images/ImageVariants";
import prisma from "@/lib/prisma";
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const image = await prisma.portfolioImage.findUnique({
where: { id },
include: {
type: true,
metadata: true,
categories: true,
colors: { include: { color: true } },
tags: true,
variants: true
}
})
const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
if (!image) return <div>Image not found</div>
return (
<div>PortfolioImagesEditPage</div>
<div>
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
{image ? <EditImageForm image={image} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
<div className="mt-6">
{image && <DeleteImageButton imageId={image.id} />}
</div>
<div>
{image && <ImageVariants variants={image.variants} />}
</div>
</div>
<div className="space-y-6">
<div>
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@ -1,5 +1,24 @@
export default function PortfolioImagesPage() {
import ImageList from "@/components/portfolio/images/ImageList";
import prisma from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function PortfolioImagesPage() {
const images = await prisma.portfolioImage.findMany(
{
orderBy: [{ sortIndex: 'asc' }]
}
)
return (
<div>PortfolioImagesPage</div>
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Images</h1>
<Link href="/portfolio/images/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image
</Link>
</div>
{images && images.length > 0 ? <ImageList images={images} /> : <p>There are no images yet. Consider adding some!</p>}
</div>
);
}

View File

@ -0,0 +1,26 @@
"use client"
import { deleteImage } from "@/actions/portfolio/images/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/images");
} else {
alert("Failed to delete image.");
}
}
}
return (
<Button variant="destructive" onClick={handleDelete}>
Delete Image
</Button>
);
}

View File

@ -0,0 +1,380 @@
"use client"
import { updateImage } from "@/actions/portfolio/images/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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag, PortfolioType } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/portfolio/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 { z } from "zod/v4";
type ImageWithItems = PortfolioImage & {
metadata: ImageMetadata | null,
colors: (
ImageColor & {
color: Color
}
)[],
variants: ImageVariant[],
categories: PortfolioCategory[],
tags: PortfolioTag[],
type: PortfolioType | null,
};
export default function EditImageForm({ image, categories, tags, types }:
{
image: ImageWithItems,
categories: PortfolioCategory[]
tags: PortfolioTag[],
types: PortfolioType[]
}) {
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,
setAsHeader: image.setAsHeader ?? false,
name: image.name,
altText: image.altText || "",
description: image.description || "",
fileType: image.fileType || "",
layoutGroup: image.layoutGroup || "",
fileSize: image.fileSize || undefined,
layoutOrder: image.layoutOrder || undefined,
month: image.month || undefined,
year: image.year || undefined,
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
typeId: image.typeId ?? 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">
{/* String */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Image name</FormLabel>
<FormControl>
<Input {...field} placeholder="The public display name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="altText"
render={({ field }) => (
<FormItem>
<FormLabel>Alt Text</FormLabel>
<FormControl>
<Input {...field} placeholder="Alt for this image" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Number */}
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem>
<FormLabel>Creation Month</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem>
<FormLabel>Creation Year</FormLabel>
<FormControl>
<Input {...field} type="number" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Date */}
<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>
)}
/>
{/* Select */}
<FormField
control={form.control}
name="typeId"
render={({ field }) => (
<FormItem>
<FormLabel>Art Type</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an art type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{types.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<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>
)
}}
/>
{/* Boolean */}
<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="setAsHeader"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Set as header image</FormLabel>
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
{/* Read only */}
<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} disabled /></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>FileSize</FormLabel>
<FormControl><Input type="number" {...field} disabled /></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/images/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,57 @@
"use client";
import { sortImages } from "@/actions/portfolio/images/sortImages";
import { SortableItem } from "@/components/sort/items/SortableItem";
import SortableList from "@/components/sort/lists/SortableList";
import { PortfolioImage } from "@/generated/prisma";
import { SortableItem as ItemType } from "@/types/SortableItem";
import { useEffect, useState } from "react";
export default function ImageList({ images }: { images: PortfolioImage[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: ItemType[] = images.map(image => ({
id: image.id,
sortIndex: image.sortIndex,
label: image.name || "",
}));
const handleReorder = async (items: ItemType[]) => {
await sortImages(items);
};
if (!isMounted) return null;
return (
<div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
renderItem={(item) => {
const image = images.find(g => g.id === item.id)!;
return (
<SortableItem
key={image.id}
id={image.id}
item={
{
id: image.id,
name: image.name,
href: `/portfolio/images/${image.id}`,
fileKey: image.fileKey,
altText: image.altText || "",
published: image.published,
type: 'image'
}
}
/>
);
}}
/>
</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 { createImage } from "@/actions/portfolio/images/createImage";
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/portfolio/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 createImage(values)
if (image) {
toast.success("Image created")
router.push(`/portfolio/images/${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,45 @@
import EditImageForm from "@/components/portfolio/images/EditImageForm";
import prisma from "@/lib/prisma";
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
const { id } = await params;
const image = await prisma.portfolioImage.findUnique({
where: { id },
include: {
type: true,
metadata: true,
categories: true,
colors: { include: { color: true } },
tags: true,
variants: true
}
})
const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
if (!image) return <div>Image not found</div>
return (
<div>
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
{image ? <EditImageForm image={image} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
<div className="mt-6">
{image && <DeleteImageButton imageId={image.id} />}
</div>
<div>
{image && <ImageVariants variants={image.variants} />}
</div>
</div>
<div className="space-y-6">
<div>
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, PencilIcon } from 'lucide-react';
import Link from 'next/link';
type SupportedTypes = 'image' | 'type' | 'category' | 'tag';
type SortableItemProps = {
id: string;
item: {
id: string;
name: string;
href: string;
fileKey?: string;
altText?: string;
published?: boolean;
type?: SupportedTypes;
count?: number;
textLabel?: string;
};
};
export function SortableItem({ id, item }: SortableItemProps) {
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>
<Card>
<CardHeader>
<CardTitle className="text-xl truncate">{item.name}</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Link
href={item.href}
className="w-full"
>
<Button variant="default" className="w-full flex items-center gap-2">
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</Link>
</CardFooter>
</Card>
</div>
);
}

View File

@ -0,0 +1,71 @@
'use client';
import { SortableItem } from '@/types/SortableItem';
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';
interface Props {
items: SortableItem[];
onReorder: (items: SortableItem[]) => void;
renderItem: (item: SortableItem) => React.ReactNode;
}
export default function SortableList({
items,
onReorder,
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">
<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,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;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4"
export const categorySchema = z.object({
name: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
description: z.string().optional(),
})
export type categorySchema = z.infer<typeof categorySchema>

View File

@ -1,4 +1,4 @@
import * as z from "zod/v4";
import { z } from "zod/v4"
export const imageUploadSchema = z.object({
file: z
@ -11,6 +11,7 @@ export const imageUploadSchema = z.object({
export const imageSchema = z.object({
fileKey: z.string().min(1, "File key is required"),
originalFile: z.string().min(1, "Original file is required"),
name: z.string().min(1, "Name is required"),
nsfw: z.boolean(),
published: z.boolean(),
setAsHeader: z.boolean(),
@ -18,20 +19,21 @@ export const imageSchema = z.object({
altText: z.string().optional(),
description: z.string().optional(),
fileType: z.string().optional(),
group: z.string().optional(),
kind: z.string().optional(),
layoutGroup: z.string().optional(),
name: z.string().optional(),
series: z.string().optional(),
slug: z.string().optional(),
type: z.string().optional(),
fileSize: z.number().optional(),
layoutOrder: z.number().optional(),
month: z.number().optional(),
year: z.number().optional(),
creationDate: z.date().optional(),
// group: z.string().optional(),
// kind: z.string().optional(),
// series: z.string().optional(),
// slug: z.string().optional(),
// fileSize: z.number().optional(),
typeId: z.string().optional(),
colorIds: z.array(z.string()).optional(),
categoryIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
artTypeId: z.string().optional(),
})

View File

@ -1,9 +1,10 @@
import * as z from "zod/v4";
import { z } from "zod/v4"
export const artTypeSchema = z.object({
export const tagSchema = z.object({
name: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
description: z.string().optional(),
})
export type artTypeSchema = z.infer<typeof artTypeSchema>
export type tagSchema = z.infer<typeof tagSchema>

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4"
export const typeSchema = z.object({
name: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
description: z.string().optional(),
})
export type typeSchema = z.infer<typeof typeSchema>

View File

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