Refactor images
This commit is contained in:
@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "PORT=3001 NODE_OPTIONS='--max-old-space-size=4096' next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
@ -0,0 +1,371 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PortfolioImage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"fileKey" TEXT NOT NULL,
|
||||||
|
"originalFile" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"nsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"setAsHeader" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"altText" TEXT,
|
||||||
|
"description" TEXT,
|
||||||
|
"fileType" TEXT,
|
||||||
|
"layoutGroup" TEXT,
|
||||||
|
"layoutOrder" INTEGER,
|
||||||
|
"month" INTEGER,
|
||||||
|
"year" INTEGER,
|
||||||
|
"creationDate" TIMESTAMP(3),
|
||||||
|
"typeId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioImage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PortfolioType" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioType_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PortfolioCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PortfolioTag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PortfolioTag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Color" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"hex" TEXT,
|
||||||
|
"blue" INTEGER,
|
||||||
|
"green" INTEGER,
|
||||||
|
"red" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "Color_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImageColor" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"imageId" TEXT NOT NULL,
|
||||||
|
"colorId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ImageColor_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImageMetadata" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"imageId" TEXT NOT NULL,
|
||||||
|
"depth" TEXT NOT NULL,
|
||||||
|
"format" TEXT NOT NULL,
|
||||||
|
"space" TEXT NOT NULL,
|
||||||
|
"channels" INTEGER NOT NULL,
|
||||||
|
"height" INTEGER NOT NULL,
|
||||||
|
"width" INTEGER NOT NULL,
|
||||||
|
"autoOrientH" INTEGER,
|
||||||
|
"autoOrientW" INTEGER,
|
||||||
|
"bitsPerSample" INTEGER,
|
||||||
|
"density" INTEGER,
|
||||||
|
"hasAlpha" BOOLEAN,
|
||||||
|
"hasProfile" BOOLEAN,
|
||||||
|
"isPalette" BOOLEAN,
|
||||||
|
"isProgressive" BOOLEAN,
|
||||||
|
|
||||||
|
CONSTRAINT "ImageMetadata_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImageVariant" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"imageId" TEXT NOT NULL,
|
||||||
|
"s3Key" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"height" INTEGER NOT NULL,
|
||||||
|
"width" INTEGER NOT NULL,
|
||||||
|
"fileExtension" TEXT,
|
||||||
|
"mimeType" TEXT,
|
||||||
|
"url" TEXT,
|
||||||
|
"sizeBytes" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "ImageVariant_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionType" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionType_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionOption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionOption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionExtra" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionExtra_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionCustomInput" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"fieldId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionCustomInput_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionTypeOption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"typeId" TEXT NOT NULL,
|
||||||
|
"optionId" TEXT NOT NULL,
|
||||||
|
"priceRange" TEXT,
|
||||||
|
"pricePercent" DOUBLE PRECISION,
|
||||||
|
"price" DOUBLE PRECISION,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionTypeOption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionTypeExtra" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"typeId" TEXT NOT NULL,
|
||||||
|
"extraId" TEXT NOT NULL,
|
||||||
|
"priceRange" TEXT,
|
||||||
|
"pricePercent" DOUBLE PRECISION,
|
||||||
|
"price" DOUBLE PRECISION,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionTypeExtra_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionTypeCustomInput" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"typeId" TEXT NOT NULL,
|
||||||
|
"customInputId" TEXT NOT NULL,
|
||||||
|
"inputType" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"required" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionTypeCustomInput_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TermsOfService" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"version" SERIAL NOT NULL,
|
||||||
|
"markdown" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TermsOfService_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CommissionRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"customerName" TEXT NOT NULL,
|
||||||
|
"customerEmail" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"optionId" TEXT,
|
||||||
|
"typeId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CommissionRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_PortfolioImageToPortfolioTag" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_PortfolioImageToPortfolioTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_PortfolioCategoryToPortfolioImage" (
|
||||||
|
"A" TEXT NOT NULL,
|
||||||
|
"B" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_PortfolioCategoryToPortfolioImage_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioImage_fileKey_key" ON "PortfolioImage"("fileKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioImage_originalFile_key" ON "PortfolioImage"("originalFile");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioType_name_key" ON "PortfolioType"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioType_slug_key" ON "PortfolioType"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioCategory_name_key" ON "PortfolioCategory"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioCategory_slug_key" ON "PortfolioCategory"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioTag_name_key" ON "PortfolioTag"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PortfolioTag_slug_key" ON "PortfolioTag"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Color_name_key" ON "Color"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ImageColor_imageId_type_key" ON "ImageColor"("imageId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ImageMetadata_imageId_key" ON "ImageMetadata"("imageId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ImageVariant_imageId_type_key" ON "ImageVariant"("imageId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommissionCustomInput_name_key" ON "CommissionCustomInput"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommissionTypeOption_typeId_optionId_key" ON "CommissionTypeOption"("typeId", "optionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommissionTypeExtra_typeId_extraId_key" ON "CommissionTypeExtra"("typeId", "extraId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CommissionTypeCustomInput_typeId_customInputId_key" ON "CommissionTypeCustomInput"("typeId", "customInputId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_PortfolioImageToPortfolioTag_B_index" ON "_PortfolioImageToPortfolioTag"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_PortfolioCategoryToPortfolioImage_B_index" ON "_PortfolioCategoryToPortfolioImage"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PortfolioImage" ADD CONSTRAINT "PortfolioImage_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "PortfolioType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_colorId_fkey" FOREIGN KEY ("colorId") REFERENCES "Color"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImageMetadata" ADD CONSTRAINT "ImageMetadata_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionTypeCustomInput" ADD CONSTRAINT "CommissionTypeCustomInput_customInputId_fkey" FOREIGN KEY ("customInputId") REFERENCES "CommissionCustomInput"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PortfolioImage" ADD COLUMN "fileSize" INTEGER;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
@ -14,6 +14,165 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portfolio
|
||||||
|
model PortfolioImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
|
||||||
|
fileKey String @unique
|
||||||
|
originalFile String @unique
|
||||||
|
name String
|
||||||
|
nsfw Boolean @default(false)
|
||||||
|
published Boolean @default(true)
|
||||||
|
setAsHeader Boolean @default(false)
|
||||||
|
|
||||||
|
altText String?
|
||||||
|
description String?
|
||||||
|
fileType String?
|
||||||
|
layoutGroup String?
|
||||||
|
fileSize Int?
|
||||||
|
layoutOrder Int?
|
||||||
|
month Int?
|
||||||
|
year Int?
|
||||||
|
creationDate DateTime?
|
||||||
|
// group String?
|
||||||
|
// kind String?
|
||||||
|
// series String?
|
||||||
|
// slug String?
|
||||||
|
// fileSize Int?
|
||||||
|
|
||||||
|
typeId String?
|
||||||
|
type PortfolioType? @relation(fields: [typeId], references: [id])
|
||||||
|
|
||||||
|
metadata ImageMetadata?
|
||||||
|
|
||||||
|
categories PortfolioCategory[]
|
||||||
|
colors ImageColor[]
|
||||||
|
tags PortfolioTag[]
|
||||||
|
variants ImageVariant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
|
description String?
|
||||||
|
|
||||||
|
images PortfolioImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
|
description String?
|
||||||
|
|
||||||
|
images PortfolioImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PortfolioTag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
sortIndex Int @default(0)
|
||||||
|
|
||||||
|
name String @unique
|
||||||
|
slug String @unique
|
||||||
|
|
||||||
|
description String?
|
||||||
|
|
||||||
|
images PortfolioImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Color {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
name String @unique
|
||||||
|
type String
|
||||||
|
|
||||||
|
hex String?
|
||||||
|
blue Int?
|
||||||
|
green Int?
|
||||||
|
red Int?
|
||||||
|
|
||||||
|
images ImageColor[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ImageColor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
imageId String
|
||||||
|
colorId String
|
||||||
|
type String
|
||||||
|
|
||||||
|
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||||
|
color Color @relation(fields: [colorId], references: [id])
|
||||||
|
|
||||||
|
@@unique([imageId, type])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ImageMetadata {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
imageId String @unique
|
||||||
|
depth String
|
||||||
|
format String
|
||||||
|
space String
|
||||||
|
channels Int
|
||||||
|
height Int
|
||||||
|
width Int
|
||||||
|
|
||||||
|
autoOrientH Int?
|
||||||
|
autoOrientW Int?
|
||||||
|
bitsPerSample Int?
|
||||||
|
density Int?
|
||||||
|
hasAlpha Boolean?
|
||||||
|
hasProfile Boolean?
|
||||||
|
isPalette Boolean?
|
||||||
|
isProgressive Boolean?
|
||||||
|
|
||||||
|
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ImageVariant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
imageId String
|
||||||
|
s3Key String
|
||||||
|
type String
|
||||||
|
height Int
|
||||||
|
width Int
|
||||||
|
|
||||||
|
fileExtension String?
|
||||||
|
mimeType String?
|
||||||
|
url String?
|
||||||
|
sizeBytes Int?
|
||||||
|
|
||||||
|
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||||
|
|
||||||
|
@@unique([imageId, type])
|
||||||
|
}
|
||||||
|
|
||||||
model CommissionType {
|
model CommissionType {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -149,159 +308,3 @@ model CommissionRequest {
|
|||||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PortfolioImage {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
sortIndex Int @default(0)
|
|
||||||
|
|
||||||
fileKey String @unique
|
|
||||||
originalFile String @unique
|
|
||||||
nsfw Boolean @default(false)
|
|
||||||
published Boolean @default(false)
|
|
||||||
setAsHeader Boolean @default(false)
|
|
||||||
|
|
||||||
altText String?
|
|
||||||
description String?
|
|
||||||
fileType String?
|
|
||||||
group String?
|
|
||||||
kind String?
|
|
||||||
layoutGroup String?
|
|
||||||
name String?
|
|
||||||
series String?
|
|
||||||
slug String?
|
|
||||||
type String?
|
|
||||||
fileSize Int?
|
|
||||||
layoutOrder Int?
|
|
||||||
month Int?
|
|
||||||
year Int?
|
|
||||||
creationDate DateTime?
|
|
||||||
|
|
||||||
artTypeId String?
|
|
||||||
artType PortfolioArtType? @relation(fields: [artTypeId], references: [id])
|
|
||||||
metadata ImageMetadata?
|
|
||||||
categories PortfolioCategory[]
|
|
||||||
colors ImageColor[]
|
|
||||||
tags PortfolioTag[]
|
|
||||||
variants ImageVariant[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PortfolioArtType {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
sortIndex Int @default(0)
|
|
||||||
|
|
||||||
name String @unique
|
|
||||||
|
|
||||||
slug String?
|
|
||||||
description String?
|
|
||||||
|
|
||||||
images PortfolioImage[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PortfolioCategory {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
sortIndex Int @default(0)
|
|
||||||
|
|
||||||
name String @unique
|
|
||||||
|
|
||||||
slug String?
|
|
||||||
description String?
|
|
||||||
|
|
||||||
images PortfolioImage[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PortfolioTag {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
sortIndex Int @default(0)
|
|
||||||
|
|
||||||
name String @unique
|
|
||||||
|
|
||||||
slug String?
|
|
||||||
description String?
|
|
||||||
|
|
||||||
images PortfolioImage[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Color {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
name String @unique
|
|
||||||
type String
|
|
||||||
|
|
||||||
hex String?
|
|
||||||
blue Int?
|
|
||||||
green Int?
|
|
||||||
red Int?
|
|
||||||
|
|
||||||
images ImageColor[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model ImageColor {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
imageId String
|
|
||||||
colorId String
|
|
||||||
type String
|
|
||||||
|
|
||||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
|
||||||
color Color @relation(fields: [colorId], references: [id])
|
|
||||||
|
|
||||||
@@unique([imageId, type])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ImageMetadata {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
imageId String @unique
|
|
||||||
depth String
|
|
||||||
format String
|
|
||||||
space String
|
|
||||||
channels Int
|
|
||||||
height Int
|
|
||||||
width Int
|
|
||||||
|
|
||||||
autoOrientH Int?
|
|
||||||
autoOrientW Int?
|
|
||||||
bitsPerSample Int?
|
|
||||||
density Int?
|
|
||||||
hasAlpha Boolean?
|
|
||||||
hasProfile Boolean?
|
|
||||||
isPalette Boolean?
|
|
||||||
isProgressive Boolean?
|
|
||||||
|
|
||||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ImageVariant {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
imageId String
|
|
||||||
s3Key String
|
|
||||||
type String
|
|
||||||
height Int
|
|
||||||
width Int
|
|
||||||
|
|
||||||
fileExtension String?
|
|
||||||
mimeType String?
|
|
||||||
url String?
|
|
||||||
sizeBytes Int?
|
|
||||||
|
|
||||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
|
||||||
|
|
||||||
@@unique([imageId, type])
|
|
||||||
}
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { commissionTypeSchema } from "@/schemas/commissionType"
|
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
|
||||||
|
|
||||||
export async function createCommissionOption(data: { name: string }) {
|
export async function createCommissionOption(data: { name: string }) {
|
||||||
return await prisma.commissionOption.create({
|
return await prisma.commissionOption.create({
|
@ -1,7 +1,7 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { commissionTypeSchema } from "@/schemas/commissionType"
|
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
|
||||||
import * as z from "zod/v4"
|
import * as z from "zod/v4"
|
||||||
|
|
||||||
export async function updateCommissionType(
|
export async function updateCommissionType(
|
189
src/actions/portfolio/images/createImage.ts
Normal file
189
src/actions/portfolio/images/createImage.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { s3 } from "@/lib/s3";
|
||||||
|
import { imageUploadSchema } from "@/schemas/portfolio/imageSchema";
|
||||||
|
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export async function createImage(values: z.infer<typeof imageUploadSchema>) {
|
||||||
|
const imageFile = values.file[0];
|
||||||
|
|
||||||
|
if (!(imageFile instanceof File)) {
|
||||||
|
console.log("No image or invalid type");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = imageFile.name;
|
||||||
|
const fileType = imageFile.type;
|
||||||
|
const fileSize = imageFile.size;
|
||||||
|
const lastModified = new Date(imageFile.lastModified);
|
||||||
|
|
||||||
|
const fileKey = uuidv4();
|
||||||
|
|
||||||
|
const arrayBuffer = await imageFile.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
const realFileType = fileType.split("/")[1];
|
||||||
|
const originalKey = `original/${fileKey}.${realFileType}`;
|
||||||
|
const modifiedKey = `modified/${fileKey}.webp`;
|
||||||
|
const resizedKey = `resized/${fileKey}.webp`;
|
||||||
|
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
||||||
|
|
||||||
|
const sharpData = sharp(buffer);
|
||||||
|
const metadata = await sharpData.metadata();
|
||||||
|
|
||||||
|
//--- Original file
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: "gaertan",
|
||||||
|
Key: originalKey,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: "image/" + metadata.format,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
//--- Modified file
|
||||||
|
const modifiedBuffer = await sharp(buffer)
|
||||||
|
.toFormat('webp')
|
||||||
|
.toBuffer()
|
||||||
|
const modifiedMetadata = await sharp(modifiedBuffer).metadata();
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: "gaertan",
|
||||||
|
Key: modifiedKey,
|
||||||
|
Body: modifiedBuffer,
|
||||||
|
ContentType: "image/" + modifiedMetadata.format,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
//--- Resized file
|
||||||
|
const { width, height } = modifiedMetadata;
|
||||||
|
const targetSize = 400;
|
||||||
|
let resizeOptions;
|
||||||
|
if (width && height) {
|
||||||
|
if (height < width) {
|
||||||
|
resizeOptions = { height: targetSize };
|
||||||
|
} else {
|
||||||
|
resizeOptions = { width: targetSize };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resizeOptions = { height: targetSize };
|
||||||
|
}
|
||||||
|
const resizedBuffer = await sharp(modifiedBuffer)
|
||||||
|
.resize({ ...resizeOptions, withoutEnlargement: true })
|
||||||
|
.toFormat('webp')
|
||||||
|
.toBuffer();
|
||||||
|
const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: "gaertan",
|
||||||
|
Key: resizedKey,
|
||||||
|
Body: resizedBuffer,
|
||||||
|
ContentType: "image/" + resizedMetadata.format,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
//--- Thumbnail file
|
||||||
|
const thumbnailTargetSize = 160;
|
||||||
|
let thumbnailOptions;
|
||||||
|
if (width && height) {
|
||||||
|
if (height < width) {
|
||||||
|
thumbnailOptions = { height: thumbnailTargetSize };
|
||||||
|
} else {
|
||||||
|
thumbnailOptions = { width: thumbnailTargetSize };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thumbnailOptions = { height: thumbnailTargetSize };
|
||||||
|
}
|
||||||
|
const thumbnailBuffer = await sharp(modifiedBuffer)
|
||||||
|
.resize({ ...thumbnailOptions, withoutEnlargement: true })
|
||||||
|
.toFormat('webp')
|
||||||
|
.toBuffer();
|
||||||
|
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: "gaertan",
|
||||||
|
Key: thumbnailKey,
|
||||||
|
Body: thumbnailBuffer,
|
||||||
|
ContentType: "image/" + thumbnailMetadata.format,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const image = await prisma.portfolioImage.create({
|
||||||
|
data: {
|
||||||
|
name: fileName,
|
||||||
|
fileKey,
|
||||||
|
originalFile: fileName,
|
||||||
|
creationDate: lastModified,
|
||||||
|
fileType: realFileType,
|
||||||
|
fileSize: fileSize,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.imageMetadata.create({
|
||||||
|
data: {
|
||||||
|
imageId: image.id,
|
||||||
|
format: metadata.format || "unknown",
|
||||||
|
width: metadata.width || 0,
|
||||||
|
height: metadata.height || 0,
|
||||||
|
space: metadata.space || "unknown",
|
||||||
|
channels: metadata.channels || 0,
|
||||||
|
depth: metadata.depth || "unknown",
|
||||||
|
density: metadata.density ?? undefined,
|
||||||
|
bitsPerSample: metadata.bitsPerSample ?? undefined,
|
||||||
|
isProgressive: metadata.isProgressive ?? undefined,
|
||||||
|
isPalette: metadata.isPalette ?? undefined,
|
||||||
|
hasProfile: metadata.hasProfile ?? undefined,
|
||||||
|
hasAlpha: metadata.hasAlpha ?? undefined,
|
||||||
|
autoOrientW: metadata.autoOrient?.width ?? undefined,
|
||||||
|
autoOrientH: metadata.autoOrient?.height ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.imageVariant.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
s3Key: originalKey,
|
||||||
|
type: "original",
|
||||||
|
height: metadata.height,
|
||||||
|
width: metadata.width,
|
||||||
|
fileExtension: metadata.format,
|
||||||
|
mimeType: "image/" + metadata.format,
|
||||||
|
sizeBytes: metadata.size,
|
||||||
|
imageId: image.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
s3Key: modifiedKey,
|
||||||
|
type: "modified",
|
||||||
|
height: modifiedMetadata.height,
|
||||||
|
width: modifiedMetadata.width,
|
||||||
|
fileExtension: modifiedMetadata.format,
|
||||||
|
mimeType: "image/" + modifiedMetadata.format,
|
||||||
|
sizeBytes: modifiedMetadata.size,
|
||||||
|
imageId: image.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
s3Key: resizedKey,
|
||||||
|
type: "resized",
|
||||||
|
height: resizedMetadata.height,
|
||||||
|
width: resizedMetadata.width,
|
||||||
|
fileExtension: resizedMetadata.format,
|
||||||
|
mimeType: "image/" + resizedMetadata.format,
|
||||||
|
sizeBytes: resizedMetadata.size,
|
||||||
|
imageId: image.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
s3Key: thumbnailKey,
|
||||||
|
type: "thumbnail",
|
||||||
|
height: thumbnailMetadata.height,
|
||||||
|
width: thumbnailMetadata.width,
|
||||||
|
fileExtension: thumbnailMetadata.format,
|
||||||
|
mimeType: "image/" + thumbnailMetadata.format,
|
||||||
|
sizeBytes: thumbnailMetadata.size,
|
||||||
|
imageId: image.id
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
67
src/actions/portfolio/images/deleteImage.ts
Normal file
67
src/actions/portfolio/images/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/images/generateImageColors.ts
Normal file
66
src/actions/portfolio/images/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 },
|
||||||
|
});
|
||||||
|
}
|
15
src/actions/portfolio/images/sortImages.ts
Normal file
15
src/actions/portfolio/images/sortImages.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { SortableItem } from "@/types/SortableItem";
|
||||||
|
|
||||||
|
export async function sortImages(items: SortableItem[]) {
|
||||||
|
await Promise.all(
|
||||||
|
items.map(item =>
|
||||||
|
prisma.portfolioImage.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { sortIndex: item.sortIndex },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
70
src/actions/portfolio/images/updateImage.ts
Normal file
70
src/actions/portfolio/images/updateImage.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { imageSchema } from "@/schemas/portfolio/imageSchema";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export async function updateImage(
|
||||||
|
values: z.infer<typeof imageSchema>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
const validated = imageSchema.safeParse(values);
|
||||||
|
if (!validated.success) {
|
||||||
|
throw new Error("Invalid image data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
fileKey,
|
||||||
|
originalFile,
|
||||||
|
nsfw,
|
||||||
|
published,
|
||||||
|
altText,
|
||||||
|
description,
|
||||||
|
fileType,
|
||||||
|
name,
|
||||||
|
fileSize,
|
||||||
|
creationDate,
|
||||||
|
tagIds,
|
||||||
|
categoryIds
|
||||||
|
} = validated.data;
|
||||||
|
|
||||||
|
const updatedImage = await prisma.portfolioImage.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
fileKey,
|
||||||
|
originalFile,
|
||||||
|
nsfw,
|
||||||
|
published,
|
||||||
|
altText,
|
||||||
|
description,
|
||||||
|
fileType,
|
||||||
|
name,
|
||||||
|
fileSize,
|
||||||
|
creationDate,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tagIds) {
|
||||||
|
await prisma.portfolioImage.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
tags: {
|
||||||
|
set: tagIds.map(id => ({ id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryIds) {
|
||||||
|
await prisma.portfolioImage.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
categories: {
|
||||||
|
set: categoryIds.map(id => ({ id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedImage
|
||||||
|
}
|
@ -1,5 +1,48 @@
|
|||||||
export default function PortfolioImagesEditPage() {
|
import DeleteImageButton from "@/components/portfolio/images/DeleteImageButton";
|
||||||
|
import EditImageForm from "@/components/portfolio/images/EditImageForm";
|
||||||
|
import ImageColors from "@/components/portfolio/images/ImageColors";
|
||||||
|
import ImageVariants from "@/components/portfolio/images/ImageVariants";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const image = await prisma.portfolioImage.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
metadata: true,
|
||||||
|
categories: true,
|
||||||
|
colors: { include: { color: true } },
|
||||||
|
tags: true,
|
||||||
|
variants: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
|
||||||
|
if (!image) return <div>Image not found</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>PortfolioImagesEditPage</div>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
{image ? <EditImageForm image={image} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
|
||||||
|
<div className="mt-6">
|
||||||
|
{image && <DeleteImageButton imageId={image.id} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{image && <ImageVariants variants={image.variants} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,5 +1,10 @@
|
|||||||
|
import UploadImageForm from "@/components/portfolio/images/UploadImageForm";
|
||||||
|
|
||||||
export default function PortfolioImagesNewPage() {
|
export default function PortfolioImagesNewPage() {
|
||||||
return (
|
return (
|
||||||
<div>PortfolioImagesNewPage</div>
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Upload image</h1>
|
||||||
|
<UploadImageForm />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,5 +1,24 @@
|
|||||||
export default function PortfolioImagesPage() {
|
import ImageList from "@/components/portfolio/images/ImageList";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function PortfolioImagesPage() {
|
||||||
|
const images = await prisma.portfolioImage.findMany(
|
||||||
|
{
|
||||||
|
orderBy: [{ sortIndex: 'asc' }]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>PortfolioImagesPage</div>
|
<div>
|
||||||
|
<div className="flex gap-4 justify-between pb-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Images</h1>
|
||||||
|
<Link href="/portfolio/images/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||||
|
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{images && images.length > 0 ? <ImageList images={images} /> : <p>There are no images yet. Consider adding some!</p>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
26
src/components/portfolio/images/DeleteImageButton.tsx
Normal file
26
src/components/portfolio/images/DeleteImageButton.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { deleteImage } from "@/actions/portfolio/images/deleteImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function DeleteImageButton({ imageId }: { imageId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (confirm("Are you sure you want to delete this image? This action is irreversible.")) {
|
||||||
|
const result = await deleteImage(imageId);
|
||||||
|
if (result?.success) {
|
||||||
|
router.push("/portfolio/images");
|
||||||
|
} else {
|
||||||
|
alert("Failed to delete image.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
|
Delete Image
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
380
src/components/portfolio/images/EditImageForm.tsx
Normal file
380
src/components/portfolio/images/EditImageForm.tsx
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { updateImage } from "@/actions/portfolio/images/updateImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import MultipleSelector from "@/components/ui/multiselect";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag, PortfolioType } from "@/generated/prisma";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { imageSchema } from "@/schemas/portfolio/imageSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
type ImageWithItems = PortfolioImage & {
|
||||||
|
metadata: ImageMetadata | null,
|
||||||
|
colors: (
|
||||||
|
ImageColor & {
|
||||||
|
color: Color
|
||||||
|
}
|
||||||
|
)[],
|
||||||
|
variants: ImageVariant[],
|
||||||
|
categories: PortfolioCategory[],
|
||||||
|
tags: PortfolioTag[],
|
||||||
|
type: PortfolioType | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default function EditImageForm({ image, categories, tags, types }:
|
||||||
|
{
|
||||||
|
image: ImageWithItems,
|
||||||
|
categories: PortfolioCategory[]
|
||||||
|
tags: PortfolioTag[],
|
||||||
|
types: PortfolioType[]
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useForm<z.infer<typeof imageSchema>>({
|
||||||
|
resolver: zodResolver(imageSchema),
|
||||||
|
defaultValues: {
|
||||||
|
fileKey: image.fileKey,
|
||||||
|
originalFile: image.originalFile,
|
||||||
|
nsfw: image.nsfw ?? false,
|
||||||
|
published: image.nsfw ?? false,
|
||||||
|
setAsHeader: image.setAsHeader ?? false,
|
||||||
|
name: image.name,
|
||||||
|
|
||||||
|
altText: image.altText || "",
|
||||||
|
description: image.description || "",
|
||||||
|
fileType: image.fileType || "",
|
||||||
|
layoutGroup: image.layoutGroup || "",
|
||||||
|
fileSize: image.fileSize || undefined,
|
||||||
|
layoutOrder: image.layoutOrder || undefined,
|
||||||
|
month: image.month || undefined,
|
||||||
|
year: image.year || undefined,
|
||||||
|
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
|
||||||
|
|
||||||
|
typeId: image.typeId ?? undefined,
|
||||||
|
tagIds: image.tags?.map(tag => tag.id) ?? [],
|
||||||
|
categoryIds: image.categories?.map(cat => cat.id) ?? [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof imageSchema>) {
|
||||||
|
const updatedImage = await updateImage(values, image.id)
|
||||||
|
if (updatedImage) {
|
||||||
|
toast.success("Image updated")
|
||||||
|
router.push(`/portfolio`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
{/* String */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Image name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="The public display name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="altText"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Alt Text</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Alt for this image" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder="A descriptive text to the image" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Number */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="month"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Creation Month</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="year"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Creation Year</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="number" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Date */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="creationDate"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-col gap-1">
|
||||||
|
<FormLabel>Creation Date</FormLabel>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"pl-3 text-left font-normal",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={field.value}
|
||||||
|
onSelect={(date) => {
|
||||||
|
field.onChange(date)
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
fromYear={1990}
|
||||||
|
toYear={2030}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Select */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="typeId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Art Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an art type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{types.map((type) => (
|
||||||
|
<SelectItem key={type.id} value={type.id}>
|
||||||
|
{type.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="tagIds"
|
||||||
|
render={({ field }) => {
|
||||||
|
const selectedOptions = tags
|
||||||
|
.filter(tag => field.value?.includes(tag.id))
|
||||||
|
.map(tag => ({ label: tag.name, value: tag.id }));
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tags</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultipleSelector
|
||||||
|
defaultOptions={tags.map(tag => ({
|
||||||
|
label: tag.name,
|
||||||
|
value: tag.id,
|
||||||
|
}))}
|
||||||
|
placeholder="Select tags"
|
||||||
|
hidePlaceholderWhenSelected
|
||||||
|
selectFirstItem
|
||||||
|
value={selectedOptions}
|
||||||
|
onChange={(options) => {
|
||||||
|
const ids = options.map(option => option.value);
|
||||||
|
field.onChange(ids);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryIds"
|
||||||
|
render={({ field }) => {
|
||||||
|
const selectedOptions = categories
|
||||||
|
.filter(cat => field.value?.includes(cat.id))
|
||||||
|
.map(cat => ({ label: cat.name, value: cat.id }));
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Categories</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<MultipleSelector
|
||||||
|
defaultOptions={categories.map(cat => ({
|
||||||
|
label: cat.name,
|
||||||
|
value: cat.id,
|
||||||
|
}))}
|
||||||
|
placeholder="Select categories"
|
||||||
|
hidePlaceholderWhenSelected
|
||||||
|
selectFirstItem
|
||||||
|
value={selectedOptions}
|
||||||
|
onChange={(options) => {
|
||||||
|
const ids = options.map(option => option.value);
|
||||||
|
field.onChange(ids);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Boolean */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="nsfw"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>NSFW</FormLabel>
|
||||||
|
<FormDescription>This image contains sensitive or adult content.</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="published"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Publish</FormLabel>
|
||||||
|
<FormDescription>Will this image be published.</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="setAsHeader"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Set as header image</FormLabel>
|
||||||
|
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Read only */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="fileKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Image Key</FormLabel>
|
||||||
|
<FormControl><Input {...field} disabled /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="originalFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Original file</FormLabel>
|
||||||
|
<FormControl><Input {...field} disabled /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="fileType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Filetype</FormLabel>
|
||||||
|
<FormControl><Input {...field} disabled /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="fileSize"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>FileSize</FormLabel>
|
||||||
|
<FormControl><Input type="number" {...field} disabled /></FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
}
|
50
src/components/portfolio/images/ImageColors.tsx
Normal file
50
src/components/portfolio/images/ImageColors.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { generateImageColors } from "@/actions/portfolio/images/generateImageColors";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Color, ImageColor } from "@/generated/prisma";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type ColorWithItems = ImageColor & {
|
||||||
|
color: Color
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ImageColors({ colors: initialColors, imageId, fileKey, fileType }: { colors: ColorWithItems[], imageId: string, fileKey: string, fileType?: string }) {
|
||||||
|
const [colors, setColors] = useState(initialColors);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const newColors = await generateImageColors(imageId, fileKey, fileType);
|
||||||
|
setColors(newColors);
|
||||||
|
toast.success("Colors extracted successfully");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to extract colors");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="font-semibold text-lg">Image Colors</h2>
|
||||||
|
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
|
||||||
|
{isPending ? "Extracting..." : "Generate Palette"}
|
||||||
|
</Button>
|
||||||
|
</div >
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{colors.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.imageId}-${item.type}`}
|
||||||
|
className="w-10 h-10 rounded"
|
||||||
|
style={{ backgroundColor: item.color?.hex ?? "#000000" }}
|
||||||
|
title={`${item.type} – ${item.color?.hex}`}
|
||||||
|
></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
57
src/components/portfolio/images/ImageList.tsx
Normal file
57
src/components/portfolio/images/ImageList.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { sortImages } from "@/actions/portfolio/images/sortImages";
|
||||||
|
import { SortableItem } from "@/components/sort/items/SortableItem";
|
||||||
|
import SortableList from "@/components/sort/lists/SortableList";
|
||||||
|
import { PortfolioImage } from "@/generated/prisma";
|
||||||
|
import { SortableItem as ItemType } from "@/types/SortableItem";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function ImageList({ images }: { images: PortfolioImage[] }) {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortableItems: ItemType[] = images.map(image => ({
|
||||||
|
id: image.id,
|
||||||
|
sortIndex: image.sortIndex,
|
||||||
|
label: image.name || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleReorder = async (items: ItemType[]) => {
|
||||||
|
await sortImages(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isMounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SortableList
|
||||||
|
items={sortableItems}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
renderItem={(item) => {
|
||||||
|
const image = images.find(g => g.id === item.id)!;
|
||||||
|
return (
|
||||||
|
<SortableItem
|
||||||
|
key={image.id}
|
||||||
|
id={image.id}
|
||||||
|
item={
|
||||||
|
{
|
||||||
|
id: image.id,
|
||||||
|
name: image.name,
|
||||||
|
href: `/portfolio/images/${image.id}`,
|
||||||
|
fileKey: image.fileKey,
|
||||||
|
altText: image.altText || "",
|
||||||
|
published: image.published,
|
||||||
|
type: 'image'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/portfolio/images/ImageVariants.tsx
Normal file
21
src/components/portfolio/images/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/images/UploadImageForm.tsx
Normal file
93
src/components/portfolio/images/UploadImageForm.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createImage } from "@/actions/portfolio/images/createImage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { imageUploadSchema } from "@/schemas/portfolio/imageSchema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
|
|
||||||
|
export default function UploadImageForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof imageUploadSchema>>({
|
||||||
|
resolver: zodResolver(imageUploadSchema),
|
||||||
|
defaultValues: {
|
||||||
|
file: undefined
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const file = files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
setPreview(reader.result as string);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
form.setValue("file", files);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof imageUploadSchema>) {
|
||||||
|
const image = await createImage(values)
|
||||||
|
if (image) {
|
||||||
|
toast.success("Image created")
|
||||||
|
router.push(`/portfolio/images/${image.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="file"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Choose image to upload</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => onFileChange(e)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
{
|
||||||
|
preview ?
|
||||||
|
<Image
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
/>
|
||||||
|
:
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
45
src/components/portfolio/images/page.tsx
Normal file
45
src/components/portfolio/images/page.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import EditImageForm from "@/components/portfolio/images/EditImageForm";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const image = await prisma.portfolioImage.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
metadata: true,
|
||||||
|
categories: true,
|
||||||
|
colors: { include: { color: true } },
|
||||||
|
tags: true,
|
||||||
|
variants: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
|
||||||
|
|
||||||
|
if (!image) return <div>Image not found</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div>
|
||||||
|
{image ? <EditImageForm image={image} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
|
||||||
|
<div className="mt-6">
|
||||||
|
{image && <DeleteImageButton imageId={image.id} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{image && <ImageVariants variants={image.variants} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
85
src/components/sort/items/SortableItem.tsx
Normal file
85
src/components/sort/items/SortableItem.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { GripVertical, PencilIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type SupportedTypes = 'image' | 'type' | 'category' | 'tag';
|
||||||
|
|
||||||
|
type SortableItemProps = {
|
||||||
|
id: string;
|
||||||
|
item: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
href: string;
|
||||||
|
fileKey?: string;
|
||||||
|
altText?: string;
|
||||||
|
published?: boolean;
|
||||||
|
type?: SupportedTypes;
|
||||||
|
count?: number;
|
||||||
|
textLabel?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SortableItem({ id, item }: SortableItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// const href = `/${pluralMap[item.type]}/edit/${item.id}`;
|
||||||
|
|
||||||
|
// const isVisualType = item.type === 'gallery' || item.type === 'album';
|
||||||
|
|
||||||
|
// let countLabel = '';
|
||||||
|
// if (item.count !== undefined) {
|
||||||
|
// if (item.type === 'gallery') {
|
||||||
|
// countLabel = `${item.count} album${item.count !== 1 ? 's' : ''}`;
|
||||||
|
// } else {
|
||||||
|
// countLabel = `${item.count} image${item.count !== 1 ? 's' : ''}`;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
className="relative cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...listeners}
|
||||||
|
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
|
||||||
|
title="Drag to reorder"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl truncate">{item.name}</CardTitle>
|
||||||
|
<CardDescription></CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col justify-start gap-4">
|
||||||
|
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Button variant="default" className="w-full flex items-center gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
71
src/components/sort/lists/SortableList.tsx
Normal file
71
src/components/sort/lists/SortableList.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SortableItem } from '@/types/SortableItem';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: SortableItem[];
|
||||||
|
onReorder: (items: SortableItem[]) => void;
|
||||||
|
renderItem: (item: SortableItem) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SortableList({
|
||||||
|
items,
|
||||||
|
onReorder,
|
||||||
|
renderItem,
|
||||||
|
}: Props) {
|
||||||
|
const [localItems, setLocalItems] = useState(items);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = localItems.findIndex(item => item.id === active.id);
|
||||||
|
const newIndex = localItems.findIndex(item => item.id === over.id);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
const reordered = arrayMove(localItems, oldIndex, newIndex).map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
sortIndex: index * 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLocalItems(reordered);
|
||||||
|
onReorder(reordered);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={localItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{localItems.map(item => (
|
||||||
|
<div key={item.id}>{renderItem(item)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
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;
|
9
src/schemas/portfolio/categorySchema.ts
Normal file
9
src/schemas/portfolio/categorySchema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
export const categorySchema = z.object({
|
||||||
|
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type categorySchema = z.infer<typeof categorySchema>
|
@ -1,4 +1,4 @@
|
|||||||
import * as z from "zod/v4";
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export const imageUploadSchema = z.object({
|
export const imageUploadSchema = z.object({
|
||||||
file: z
|
file: z
|
||||||
@ -11,6 +11,7 @@ export const imageUploadSchema = z.object({
|
|||||||
export const imageSchema = z.object({
|
export const imageSchema = z.object({
|
||||||
fileKey: z.string().min(1, "File key is required"),
|
fileKey: z.string().min(1, "File key is required"),
|
||||||
originalFile: z.string().min(1, "Original file is required"),
|
originalFile: z.string().min(1, "Original file is required"),
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
nsfw: z.boolean(),
|
nsfw: z.boolean(),
|
||||||
published: z.boolean(),
|
published: z.boolean(),
|
||||||
setAsHeader: z.boolean(),
|
setAsHeader: z.boolean(),
|
||||||
@ -18,20 +19,21 @@ export const imageSchema = z.object({
|
|||||||
altText: z.string().optional(),
|
altText: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
fileType: z.string().optional(),
|
fileType: z.string().optional(),
|
||||||
group: z.string().optional(),
|
|
||||||
kind: z.string().optional(),
|
|
||||||
layoutGroup: z.string().optional(),
|
layoutGroup: z.string().optional(),
|
||||||
name: z.string().optional(),
|
|
||||||
series: z.string().optional(),
|
|
||||||
slug: z.string().optional(),
|
|
||||||
type: z.string().optional(),
|
|
||||||
fileSize: z.number().optional(),
|
fileSize: z.number().optional(),
|
||||||
layoutOrder: z.number().optional(),
|
layoutOrder: z.number().optional(),
|
||||||
month: z.number().optional(),
|
month: z.number().optional(),
|
||||||
year: z.number().optional(),
|
year: z.number().optional(),
|
||||||
creationDate: z.date().optional(),
|
creationDate: z.date().optional(),
|
||||||
|
// group: z.string().optional(),
|
||||||
|
// kind: z.string().optional(),
|
||||||
|
// series: z.string().optional(),
|
||||||
|
// slug: z.string().optional(),
|
||||||
|
// fileSize: z.number().optional(),
|
||||||
|
|
||||||
|
typeId: z.string().optional(),
|
||||||
|
|
||||||
|
colorIds: z.array(z.string()).optional(),
|
||||||
categoryIds: z.array(z.string()).optional(),
|
categoryIds: z.array(z.string()).optional(),
|
||||||
tagIds: z.array(z.string()).optional(),
|
tagIds: z.array(z.string()).optional(),
|
||||||
artTypeId: z.string().optional(),
|
|
||||||
})
|
})
|
@ -1,9 +1,10 @@
|
|||||||
import * as z from "zod/v4";
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
export const artTypeSchema = z.object({
|
export const tagSchema = z.object({
|
||||||
name: z.string().min(3, "Name is required. Min 3 characters."),
|
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type artTypeSchema = z.infer<typeof artTypeSchema>
|
export type tagSchema = z.infer<typeof tagSchema>
|
||||||
|
|
9
src/schemas/portfolio/typeSchema.ts
Normal file
9
src/schemas/portfolio/typeSchema.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod/v4"
|
||||||
|
|
||||||
|
export const typeSchema = z.object({
|
||||||
|
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||||
|
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type typeSchema = z.infer<typeof typeSchema>
|
@ -1,6 +1,5 @@
|
|||||||
export interface SortableItem {
|
export interface SortableItem {
|
||||||
id: string;
|
id: string;
|
||||||
sortIndex: number;
|
sortIndex: number;
|
||||||
label: string; // e.g., name, displayName, or handle
|
label: string;
|
||||||
secondary?: string | boolean; // optional (e.g. isPrimary)
|
|
||||||
}
|
}
|
Reference in New Issue
Block a user