Refactor images
This commit is contained in:
		@ -3,7 +3,7 @@
 | 
			
		||||
  "version": "0.1.0",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "next dev --turbopack",
 | 
			
		||||
    "dev": "PORT=3001 NODE_OPTIONS='--max-old-space-size=4096' next dev --turbopack",
 | 
			
		||||
    "build": "next build",
 | 
			
		||||
    "start": "next start",
 | 
			
		||||
    "lint": "next lint"
 | 
			
		||||
 | 
			
		||||
@ -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")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Portfolio
 | 
			
		||||
model PortfolioImage {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  fileKey      String  @unique
 | 
			
		||||
  originalFile String  @unique
 | 
			
		||||
  name         String
 | 
			
		||||
  nsfw         Boolean @default(false)
 | 
			
		||||
  published    Boolean @default(true)
 | 
			
		||||
  setAsHeader  Boolean @default(false)
 | 
			
		||||
 | 
			
		||||
  altText      String?
 | 
			
		||||
  description  String?
 | 
			
		||||
  fileType     String?
 | 
			
		||||
  layoutGroup  String?
 | 
			
		||||
  fileSize     Int?
 | 
			
		||||
  layoutOrder  Int?
 | 
			
		||||
  month        Int?
 | 
			
		||||
  year         Int?
 | 
			
		||||
  creationDate DateTime?
 | 
			
		||||
  // group        String?
 | 
			
		||||
  // kind         String?
 | 
			
		||||
  // series       String?
 | 
			
		||||
  // slug         String?
 | 
			
		||||
  // fileSize     Int?
 | 
			
		||||
 | 
			
		||||
  typeId String?
 | 
			
		||||
  type   PortfolioType? @relation(fields: [typeId], references: [id])
 | 
			
		||||
 | 
			
		||||
  metadata ImageMetadata?
 | 
			
		||||
 | 
			
		||||
  categories PortfolioCategory[]
 | 
			
		||||
  colors     ImageColor[]
 | 
			
		||||
  tags       PortfolioTag[]
 | 
			
		||||
  variants   ImageVariant[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioType {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
  slug String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioCategory {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
  slug String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioTag {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
  slug String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Color {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
  type String
 | 
			
		||||
 | 
			
		||||
  hex   String?
 | 
			
		||||
  blue  Int?
 | 
			
		||||
  green Int?
 | 
			
		||||
  red   Int?
 | 
			
		||||
 | 
			
		||||
  images ImageColor[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageColor {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId String
 | 
			
		||||
  colorId String
 | 
			
		||||
  type    String
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
  color Color          @relation(fields: [colorId], references: [id])
 | 
			
		||||
 | 
			
		||||
  @@unique([imageId, type])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageMetadata {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId  String @unique
 | 
			
		||||
  depth    String
 | 
			
		||||
  format   String
 | 
			
		||||
  space    String
 | 
			
		||||
  channels Int
 | 
			
		||||
  height   Int
 | 
			
		||||
  width    Int
 | 
			
		||||
 | 
			
		||||
  autoOrientH   Int?
 | 
			
		||||
  autoOrientW   Int?
 | 
			
		||||
  bitsPerSample Int?
 | 
			
		||||
  density       Int?
 | 
			
		||||
  hasAlpha      Boolean?
 | 
			
		||||
  hasProfile    Boolean?
 | 
			
		||||
  isPalette     Boolean?
 | 
			
		||||
  isProgressive Boolean?
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageVariant {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId String
 | 
			
		||||
  s3Key   String
 | 
			
		||||
  type    String
 | 
			
		||||
  height  Int
 | 
			
		||||
  width   Int
 | 
			
		||||
 | 
			
		||||
  fileExtension String?
 | 
			
		||||
  mimeType      String?
 | 
			
		||||
  url           String?
 | 
			
		||||
  sizeBytes     Int?
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
 | 
			
		||||
  @@unique([imageId, type])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model CommissionType {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
@ -149,159 +308,3 @@ model CommissionRequest {
 | 
			
		||||
  option   CommissionOption? @relation(fields: [optionId], references: [id])
 | 
			
		||||
  type     CommissionType?   @relation(fields: [typeId], references: [id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioImage {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  fileKey      String  @unique
 | 
			
		||||
  originalFile String  @unique
 | 
			
		||||
  nsfw         Boolean @default(false)
 | 
			
		||||
  published    Boolean @default(false)
 | 
			
		||||
  setAsHeader  Boolean @default(false)
 | 
			
		||||
 | 
			
		||||
  altText      String?
 | 
			
		||||
  description  String?
 | 
			
		||||
  fileType     String?
 | 
			
		||||
  group        String?
 | 
			
		||||
  kind         String?
 | 
			
		||||
  layoutGroup  String?
 | 
			
		||||
  name         String?
 | 
			
		||||
  series       String?
 | 
			
		||||
  slug         String?
 | 
			
		||||
  type         String?
 | 
			
		||||
  fileSize     Int?
 | 
			
		||||
  layoutOrder  Int?
 | 
			
		||||
  month        Int?
 | 
			
		||||
  year         Int?
 | 
			
		||||
  creationDate DateTime?
 | 
			
		||||
 | 
			
		||||
  artTypeId  String?
 | 
			
		||||
  artType    PortfolioArtType?   @relation(fields: [artTypeId], references: [id])
 | 
			
		||||
  metadata   ImageMetadata?
 | 
			
		||||
  categories PortfolioCategory[]
 | 
			
		||||
  colors     ImageColor[]
 | 
			
		||||
  tags       PortfolioTag[]
 | 
			
		||||
  variants   ImageVariant[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioArtType {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
 | 
			
		||||
  slug        String?
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioCategory {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
 | 
			
		||||
  slug        String?
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model PortfolioTag {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
  sortIndex Int      @default(0)
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
 | 
			
		||||
  slug        String?
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images PortfolioImage[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Color {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
  type String
 | 
			
		||||
 | 
			
		||||
  hex   String?
 | 
			
		||||
  blue  Int?
 | 
			
		||||
  green Int?
 | 
			
		||||
  red   Int?
 | 
			
		||||
 | 
			
		||||
  images ImageColor[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageColor {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId String
 | 
			
		||||
  colorId String
 | 
			
		||||
  type    String
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
  color Color          @relation(fields: [colorId], references: [id])
 | 
			
		||||
 | 
			
		||||
  @@unique([imageId, type])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageMetadata {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId  String @unique
 | 
			
		||||
  depth    String
 | 
			
		||||
  format   String
 | 
			
		||||
  space    String
 | 
			
		||||
  channels Int
 | 
			
		||||
  height   Int
 | 
			
		||||
  width    Int
 | 
			
		||||
 | 
			
		||||
  autoOrientH   Int?
 | 
			
		||||
  autoOrientW   Int?
 | 
			
		||||
  bitsPerSample Int?
 | 
			
		||||
  density       Int?
 | 
			
		||||
  hasAlpha      Boolean?
 | 
			
		||||
  hasProfile    Boolean?
 | 
			
		||||
  isPalette     Boolean?
 | 
			
		||||
  isProgressive Boolean?
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageVariant {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId String
 | 
			
		||||
  s3Key   String
 | 
			
		||||
  type    String
 | 
			
		||||
  height  Int
 | 
			
		||||
  width   Int
 | 
			
		||||
 | 
			
		||||
  fileExtension String?
 | 
			
		||||
  mimeType      String?
 | 
			
		||||
  url           String?
 | 
			
		||||
  sizeBytes     Int?
 | 
			
		||||
 | 
			
		||||
  image PortfolioImage @relation(fields: [imageId], references: [id])
 | 
			
		||||
 | 
			
		||||
  @@unique([imageId, type])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissionType"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
 | 
			
		||||
 | 
			
		||||
export async function createCommissionOption(data: { name: string }) {
 | 
			
		||||
  return await prisma.commissionOption.create({
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissionType"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
 | 
			
		||||
import * as z from "zod/v4"
 | 
			
		||||
 | 
			
		||||
export async function updateCommissionType(
 | 
			
		||||
							
								
								
									
										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 (
 | 
			
		||||
    <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() {
 | 
			
		||||
  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 (
 | 
			
		||||
    <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({
 | 
			
		||||
  file: z
 | 
			
		||||
@ -11,6 +11,7 @@ export const imageUploadSchema = z.object({
 | 
			
		||||
export const imageSchema = z.object({
 | 
			
		||||
  fileKey: z.string().min(1, "File key is required"),
 | 
			
		||||
  originalFile: z.string().min(1, "Original file is required"),
 | 
			
		||||
  name: z.string().min(1, "Name is required"),
 | 
			
		||||
  nsfw: z.boolean(),
 | 
			
		||||
  published: z.boolean(),
 | 
			
		||||
  setAsHeader: z.boolean(),
 | 
			
		||||
@ -18,20 +19,21 @@ export const imageSchema = z.object({
 | 
			
		||||
  altText: z.string().optional(),
 | 
			
		||||
  description: z.string().optional(),
 | 
			
		||||
  fileType: z.string().optional(),
 | 
			
		||||
  group: z.string().optional(),
 | 
			
		||||
  kind: z.string().optional(),
 | 
			
		||||
  layoutGroup: z.string().optional(),
 | 
			
		||||
  name: z.string().optional(),
 | 
			
		||||
  series: z.string().optional(),
 | 
			
		||||
  slug: z.string().optional(),
 | 
			
		||||
  type: z.string().optional(),
 | 
			
		||||
  fileSize: z.number().optional(),
 | 
			
		||||
  layoutOrder: z.number().optional(),
 | 
			
		||||
  month: z.number().optional(),
 | 
			
		||||
  year: z.number().optional(),
 | 
			
		||||
  creationDate: z.date().optional(),
 | 
			
		||||
  // group: z.string().optional(),
 | 
			
		||||
  // kind: z.string().optional(),
 | 
			
		||||
  // series: z.string().optional(),
 | 
			
		||||
  // slug: z.string().optional(),
 | 
			
		||||
  // fileSize: z.number().optional(),
 | 
			
		||||
 | 
			
		||||
  typeId: z.string().optional(),
 | 
			
		||||
 | 
			
		||||
  colorIds: z.array(z.string()).optional(),
 | 
			
		||||
  categoryIds: z.array(z.string()).optional(),
 | 
			
		||||
  tagIds: z.array(z.string()).optional(),
 | 
			
		||||
  artTypeId: z.string().optional(),
 | 
			
		||||
})
 | 
			
		||||
@ -1,9 +1,10 @@
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
import { z } from "zod/v4"
 | 
			
		||||
 | 
			
		||||
export const artTypeSchema = z.object({
 | 
			
		||||
export const tagSchema = z.object({
 | 
			
		||||
  name: z.string().min(3, "Name is required. Min 3 characters."),
 | 
			
		||||
  slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
 | 
			
		||||
  description: z.string().optional(),
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export type artTypeSchema = z.infer<typeof artTypeSchema>
 | 
			
		||||
export type tagSchema = z.infer<typeof tagSchema>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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 {
 | 
			
		||||
  id: string;
 | 
			
		||||
  sortIndex: number;
 | 
			
		||||
  label: string; // e.g., name, displayName, or handle
 | 
			
		||||
  secondary?: string | boolean; // optional (e.g. isPrimary)
 | 
			
		||||
  label: string;
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user