Add image handling
This commit is contained in:
2725
package-lock.json
generated
2725
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,11 +9,14 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.844.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@platejs/basic-nodes": "^49.0.0",
|
"@platejs/basic-nodes": "^49.0.0",
|
||||||
"@platejs/code-block": "^49.0.0",
|
"@platejs/code-block": "^49.0.0",
|
||||||
"@platejs/indent": "^49.0.0",
|
"@platejs/indent": "^49.0.0",
|
||||||
@ -40,20 +43,25 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next": "15.3.5",
|
"next": "15.3.5",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"node-vibrant": "^4.0.3",
|
||||||
"platejs": "^49.1.5",
|
"platejs": "^49.1.5",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.59.0",
|
"react-hook-form": "^7.59.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwind-scrollbar-hide": "^4.0.0",
|
"tailwind-scrollbar-hide": "^4.0.0",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.25.73"
|
"zod": "^3.25.73"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
197
prisma/migrations/20250712182812_add_images/migration.sql
Normal file
197
prisma/migrations/20250712182812_add_images/migration.sql
Normal 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;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PortfolioImage" ADD COLUMN "fileSize" INTEGER;
|
@ -27,6 +27,7 @@ model CommissionType {
|
|||||||
options CommissionTypeOption[]
|
options CommissionTypeOption[]
|
||||||
extras CommissionTypeExtra[]
|
extras CommissionTypeExtra[]
|
||||||
customInputs CommissionTypeCustomInput[]
|
customInputs CommissionTypeCustomInput[]
|
||||||
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionOption {
|
model CommissionOption {
|
||||||
@ -39,7 +40,8 @@ model CommissionOption {
|
|||||||
|
|
||||||
description String?
|
description String?
|
||||||
|
|
||||||
types CommissionTypeOption[]
|
types CommissionTypeOption[]
|
||||||
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionExtra {
|
model CommissionExtra {
|
||||||
@ -132,3 +134,151 @@ model TermsOfService {
|
|||||||
version Int @default(autoincrement())
|
version Int @default(autoincrement())
|
||||||
markdown String
|
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])
|
||||||
|
}
|
||||||
|
67
src/actions/portfolio/edit/deleteImage.ts
Normal file
67
src/actions/portfolio/edit/deleteImage.ts
Normal 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 };
|
||||||
|
}
|
66
src/actions/portfolio/edit/generateImageColors.ts
Normal file
66
src/actions/portfolio/edit/generateImageColors.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
74
src/actions/portfolio/edit/updateImage.ts
Normal file
74
src/actions/portfolio/edit/updateImage.ts
Normal 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
|
||||||
|
}
|
15
src/actions/portfolio/updateImageSortOrder.ts
Normal file
15
src/actions/portfolio/updateImageSortOrder.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
190
src/actions/portfolio/upload/uploadImage.ts
Normal file
190
src/actions/portfolio/upload/uploadImage.ts
Normal 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
|
||||||
|
}
|
33
src/app/api/image/[...key]/route.ts
Normal file
33
src/app/api/image/[...key]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
45
src/app/portfolio/edit/[id]/page.tsx
Normal file
45
src/app/portfolio/edit/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
28
src/app/portfolio/page.tsx
Normal file
28
src/app/portfolio/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
10
src/app/portfolio/upload/page.tsx
Normal file
10
src/app/portfolio/upload/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -12,6 +12,11 @@ export default function TopNav() {
|
|||||||
<Link href="/">Home</Link>
|
<Link href="/">Home</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
|
<Link href="/portfolio">Portfolio</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link href="/items/commissions/types">CommissionTypes</Link>
|
<Link href="/items/commissions/types">CommissionTypes</Link>
|
||||||
|
61
src/components/portfolio/ListImages.tsx
Normal file
61
src/components/portfolio/ListImages.tsx
Normal 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
26
src/components/portfolio/edit/DeleteImageButton.tsx
Normal file
26
src/components/portfolio/edit/DeleteImageButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
316
src/components/portfolio/edit/EditImageForm.tsx
Normal file
316
src/components/portfolio/edit/EditImageForm.tsx
Normal 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 >
|
||||||
|
);
|
||||||
|
}
|
50
src/components/portfolio/edit/ImageColors.tsx
Normal file
50
src/components/portfolio/edit/ImageColors.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/portfolio/edit/ImageVariants.tsx
Normal file
21
src/components/portfolio/edit/ImageVariants.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
93
src/components/portfolio/upload/UploadImageForm.tsx
Normal file
93
src/components/portfolio/upload/UploadImageForm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
88
src/components/sort/SortableImage.tsx
Normal file
88
src/components/sort/SortableImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
103
src/components/sort/SortableItem.tsx
Normal file
103
src/components/sort/SortableItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
85
src/components/sort/SortableList.tsx
Normal file
85
src/components/sort/SortableList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal 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 }
|
608
src/components/ui/multiselect.tsx
Normal file
608
src/components/ui/multiselect.tsx
Normal 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
21
src/lib/s3.ts
Normal 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 });
|
||||||
|
}
|
28
src/schemas/imageSchema.ts
Normal file
28
src/schemas/imageSchema.ts
Normal 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(),
|
||||||
|
})
|
6
src/types/SortableItem.ts
Normal file
6
src/types/SortableItem.ts
Normal 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)
|
||||||
|
}
|
4
src/types/VibrantSwatch.ts
Normal file
4
src/types/VibrantSwatch.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export type VibrantSwatch = {
|
||||||
|
_rgb: [number, number, number];
|
||||||
|
hex?: string;
|
||||||
|
};
|
9
src/utils/formatFileSize.ts
Normal file
9
src/utils/formatFileSize.ts
Normal 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`;
|
||||||
|
}
|
22
src/utils/getImageBufferFromS3.ts
Normal file
22
src/utils/getImageBufferFromS3.ts
Normal 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
96
src/utils/uploadHelper.ts
Normal 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;
|
||||||
|
// }
|
Reference in New Issue
Block a user