Add tags and categories CRUD
This commit is contained in:
		
							
								
								
									
										54
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										54
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -13,6 +13,7 @@
 | 
			
		||||
        "@hookform/resolvers": "^5.1.1",
 | 
			
		||||
        "@material/material-color-utilities": "^0.3.0",
 | 
			
		||||
        "@prisma/client": "^6.10.1",
 | 
			
		||||
        "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
        "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
        "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
        "@radix-ui/react-navigation-menu": "^1.2.13",
 | 
			
		||||
@ -21,6 +22,7 @@
 | 
			
		||||
        "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
        "class-variance-authority": "^0.7.1",
 | 
			
		||||
        "clsx": "^2.1.1",
 | 
			
		||||
        "cmdk": "^1.1.1",
 | 
			
		||||
        "date-fns": "^4.1.0",
 | 
			
		||||
        "extract-colors": "^4.2.0",
 | 
			
		||||
        "get-pixels": "^3.3.3",
 | 
			
		||||
@ -2294,6 +2296,42 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-dialog": {
 | 
			
		||||
      "version": "1.1.14",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
 | 
			
		||||
      "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/primitive": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-compose-refs": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-context": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-dismissable-layer": "1.1.10",
 | 
			
		||||
        "@radix-ui/react-focus-guards": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-focus-scope": "1.1.7",
 | 
			
		||||
        "@radix-ui/react-id": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-portal": "1.1.9",
 | 
			
		||||
        "@radix-ui/react-presence": "1.1.4",
 | 
			
		||||
        "@radix-ui/react-primitive": "2.1.3",
 | 
			
		||||
        "@radix-ui/react-slot": "1.2.3",
 | 
			
		||||
        "@radix-ui/react-use-controllable-state": "1.2.2",
 | 
			
		||||
        "aria-hidden": "^1.2.4",
 | 
			
		||||
        "react-remove-scroll": "^2.6.3"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@types/react": "*",
 | 
			
		||||
        "@types/react-dom": "*",
 | 
			
		||||
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
 | 
			
		||||
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "@types/react": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "@types/react-dom": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-direction": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
 | 
			
		||||
@ -5361,6 +5399,22 @@
 | 
			
		||||
        "node": ">=6"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/cmdk": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/react-compose-refs": "^1.1.1",
 | 
			
		||||
        "@radix-ui/react-dialog": "^1.1.6",
 | 
			
		||||
        "@radix-ui/react-id": "^1.1.0",
 | 
			
		||||
        "@radix-ui/react-primitive": "^2.0.2"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^18 || ^19 || ^19.0.0-rc",
 | 
			
		||||
        "react-dom": "^18 || ^19 || ^19.0.0-rc"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/color": {
 | 
			
		||||
      "version": "4.2.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
 | 
			
		||||
 | 
			
		||||
@ -14,6 +14,7 @@
 | 
			
		||||
    "@hookform/resolvers": "^5.1.1",
 | 
			
		||||
    "@material/material-color-utilities": "^0.3.0",
 | 
			
		||||
    "@prisma/client": "^6.10.1",
 | 
			
		||||
    "@radix-ui/react-dialog": "^1.1.14",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-navigation-menu": "^1.2.13",
 | 
			
		||||
@ -22,6 +23,7 @@
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "cmdk": "^1.1.1",
 | 
			
		||||
    "date-fns": "^4.1.0",
 | 
			
		||||
    "extract-colors": "^4.2.0",
 | 
			
		||||
    "get-pixels": "^3.3.3",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										68
									
								
								prisma/migrations/20250627223557_image_colors/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								prisma/migrations/20250627223557_image_colors/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,68 @@
 | 
			
		||||
/*
 | 
			
		||||
  Warnings:
 | 
			
		||||
 | 
			
		||||
  - You are about to drop the column `imageId` on the `ExtractColor` table. All the data in the column will be lost.
 | 
			
		||||
  - A unique constraint covering the columns `[name]` on the table `ExtractColor` will be added. If there are existing duplicate values, this will fail.
 | 
			
		||||
  - A unique constraint covering the columns `[name]` on the table `ImageColor` will be added. If there are existing duplicate values, this will fail.
 | 
			
		||||
  - Made the column `name` on table `ColorPalette` required. This step will fail if there are existing NULL values in that column.
 | 
			
		||||
  - Made the column `type` on table `ColorPalette` required. This step will fail if there are existing NULL values in that column.
 | 
			
		||||
  - Added the required column `name` to the `ExtractColor` table without a default value. This is not possible if the table is not empty.
 | 
			
		||||
  - Added the required column `name` to the `ImageColor` table without a default value. This is not possible if the table is not empty.
 | 
			
		||||
 | 
			
		||||
*/
 | 
			
		||||
-- DropForeignKey
 | 
			
		||||
ALTER TABLE "ExtractColor" DROP CONSTRAINT "ExtractColor_imageId_fkey";
 | 
			
		||||
 | 
			
		||||
-- DropForeignKey
 | 
			
		||||
ALTER TABLE "ImageColor" DROP CONSTRAINT "ImageColor_imageId_fkey";
 | 
			
		||||
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "ColorPalette" ALTER COLUMN "name" SET NOT NULL,
 | 
			
		||||
ALTER COLUMN "type" SET NOT NULL;
 | 
			
		||||
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "ExtractColor" DROP COLUMN "imageId",
 | 
			
		||||
ADD COLUMN     "name" TEXT NOT NULL;
 | 
			
		||||
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "ImageColor" ADD COLUMN     "name" TEXT NOT NULL;
 | 
			
		||||
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "_ImageToImageColor" (
 | 
			
		||||
    "A" TEXT NOT NULL,
 | 
			
		||||
    "B" TEXT NOT NULL,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "_ImageToImageColor_AB_pkey" PRIMARY KEY ("A","B")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "_ImageToExtractColor" (
 | 
			
		||||
    "A" TEXT NOT NULL,
 | 
			
		||||
    "B" TEXT NOT NULL,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "_ImageToExtractColor_AB_pkey" PRIMARY KEY ("A","B")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE INDEX "_ImageToImageColor_B_index" ON "_ImageToImageColor"("B");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE INDEX "_ImageToExtractColor_B_index" ON "_ImageToExtractColor"("B");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE UNIQUE INDEX "ExtractColor_name_key" ON "ExtractColor"("name");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE UNIQUE INDEX "ImageColor_name_key" ON "ImageColor"("name");
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageToImageColor" ADD CONSTRAINT "_ImageToImageColor_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageToImageColor" ADD CONSTRAINT "_ImageToImageColor_B_fkey" FOREIGN KEY ("B") REFERENCES "ImageColor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageToExtractColor" ADD CONSTRAINT "_ImageToExtractColor_A_fkey" FOREIGN KEY ("A") REFERENCES "ExtractColor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageToExtractColor" ADD CONSTRAINT "_ImageToExtractColor_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
@ -0,0 +1,8 @@
 | 
			
		||||
/*
 | 
			
		||||
  Warnings:
 | 
			
		||||
 | 
			
		||||
  - You are about to drop the column `imageId` on the `ImageColor` table. All the data in the column will be lost.
 | 
			
		||||
 | 
			
		||||
*/
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "ImageColor" DROP COLUMN "imageId";
 | 
			
		||||
@ -0,0 +1,61 @@
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "Category" (
 | 
			
		||||
    "id" TEXT NOT NULL,
 | 
			
		||||
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
    "updatedAt" TIMESTAMP(3) NOT NULL,
 | 
			
		||||
    "name" TEXT NOT NULL,
 | 
			
		||||
    "description" TEXT,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "Tag" (
 | 
			
		||||
    "id" TEXT NOT NULL,
 | 
			
		||||
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
    "updatedAt" TIMESTAMP(3) NOT NULL,
 | 
			
		||||
    "name" TEXT NOT NULL,
 | 
			
		||||
    "description" TEXT,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "_ImageTags" (
 | 
			
		||||
    "A" TEXT NOT NULL,
 | 
			
		||||
    "B" TEXT NOT NULL,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "_ImageTags_AB_pkey" PRIMARY KEY ("A","B")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "_ImageCategories" (
 | 
			
		||||
    "A" TEXT NOT NULL,
 | 
			
		||||
    "B" TEXT NOT NULL,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "_ImageCategories_AB_pkey" PRIMARY KEY ("A","B")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE INDEX "_ImageTags_B_index" ON "_ImageTags"("B");
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE INDEX "_ImageCategories_B_index" ON "_ImageCategories"("B");
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageTags" ADD CONSTRAINT "_ImageTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageTags" ADD CONSTRAINT "_ImageTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageCategories" ADD CONSTRAINT "_ImageCategories_A_fkey" FOREIGN KEY ("A") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "_ImageCategories" ADD CONSTRAINT "_ImageCategories_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
 | 
			
		||||
@ -103,19 +103,19 @@ model Image {
 | 
			
		||||
  // sourceId  String?
 | 
			
		||||
  // source    Source?  @relation(fields: [sourceId], references: [id])
 | 
			
		||||
 | 
			
		||||
  colors        ImageColor[]
 | 
			
		||||
  extractColors ExtractColor[]
 | 
			
		||||
  metadata      ImageMetadata[]
 | 
			
		||||
  pixels        PixelSummary[]
 | 
			
		||||
  stats         ImageStats[]
 | 
			
		||||
  theme         ThemeSeed[]
 | 
			
		||||
  variants      ImageVariant[]
 | 
			
		||||
  // 
 | 
			
		||||
  metadata ImageMetadata[]
 | 
			
		||||
  pixels   PixelSummary[]
 | 
			
		||||
  stats    ImageStats[]
 | 
			
		||||
  theme    ThemeSeed[]
 | 
			
		||||
  variants ImageVariant[]
 | 
			
		||||
 | 
			
		||||
  // albumCover    Album[]         @relation("AlbumCoverImage")
 | 
			
		||||
  // categories    Category[]      @relation("ImageCategories")
 | 
			
		||||
  // galleryCover  Gallery[]       @relation("GalleryCoverImage")
 | 
			
		||||
  palettes      ColorPalette[]  @relation("ImagePalettes")
 | 
			
		||||
  // tags          Tag[]           @relation("ImageTags")
 | 
			
		||||
  categories    Category[]     @relation("ImageCategories")
 | 
			
		||||
  colors        ImageColor[]   @relation("ImageToImageColor")
 | 
			
		||||
  extractColors ExtractColor[] @relation("ImageToExtractColor")
 | 
			
		||||
  palettes      ColorPalette[] @relation("ImagePalettes")
 | 
			
		||||
  tags          Tag[]          @relation("ImageTags")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageMetadata {
 | 
			
		||||
@ -183,8 +183,8 @@ model ColorPalette {
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String?
 | 
			
		||||
  type String?
 | 
			
		||||
  name String
 | 
			
		||||
  type String
 | 
			
		||||
 | 
			
		||||
  items  ColorPaletteItem[]
 | 
			
		||||
  images Image[]            @relation("ImagePalettes")
 | 
			
		||||
@ -207,17 +207,17 @@ model ExtractColor {
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  hex     String
 | 
			
		||||
  imageId String
 | 
			
		||||
  blue    Int
 | 
			
		||||
  green   Int
 | 
			
		||||
  red     Int
 | 
			
		||||
  name  String @unique
 | 
			
		||||
  hex   String
 | 
			
		||||
  blue  Int
 | 
			
		||||
  green Int
 | 
			
		||||
  red   Int
 | 
			
		||||
 | 
			
		||||
  area       Float?
 | 
			
		||||
  hue        Float?
 | 
			
		||||
  saturation Float?
 | 
			
		||||
 | 
			
		||||
  image Image @relation(fields: [imageId], references: [id])
 | 
			
		||||
  images Image[] @relation("ImageToExtractColor")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ImageColor {
 | 
			
		||||
@ -225,15 +225,15 @@ model ImageColor {
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  imageId String
 | 
			
		||||
  type    String
 | 
			
		||||
  name String @unique
 | 
			
		||||
  type String
 | 
			
		||||
 | 
			
		||||
  hex   String?
 | 
			
		||||
  blue  Int?
 | 
			
		||||
  green Int?
 | 
			
		||||
  red   Int?
 | 
			
		||||
 | 
			
		||||
  image Image @relation(fields: [imageId], references: [id])
 | 
			
		||||
  images Image[] @relation("ImageToImageColor")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model ThemeSeed {
 | 
			
		||||
@ -259,3 +259,27 @@ model PixelSummary {
 | 
			
		||||
 | 
			
		||||
  image Image @relation(fields: [imageId], references: [id])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Category {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images Image[] @relation("ImageCategories")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Tag {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  images Image[] @relation("ImageTags")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								src/actions/categories/createCategory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/actions/categories/createCategory.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { categorySchema } from "@/schemas/categories/categorySchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function createCategory(values: z.infer<typeof categorySchema>) {
 | 
			
		||||
  return await prisma.category.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,
 | 
			
		||||
      description: values.description
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/actions/categories/deleteCategory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/actions/categories/deleteCategory.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function deleteCategory(id: string) {
 | 
			
		||||
  await prisma.category.delete({ where: { id } });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/actions/categories/updateCategory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/actions/categories/updateCategory.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { categorySchema } from "@/schemas/categories/categorySchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function updateCategory(
 | 
			
		||||
  values: z.infer<typeof categorySchema>, 
 | 
			
		||||
  id: string
 | 
			
		||||
) {
 | 
			
		||||
  return await prisma.category.update({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: id
 | 
			
		||||
    },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,  
 | 
			
		||||
      description: values.description,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
@ -1,7 +1,64 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { s3 } from "@/lib/s3";
 | 
			
		||||
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
 | 
			
		||||
export async function deleteImage(id: string) {
 | 
			
		||||
  await prisma.gallery.delete({ where: { id } });
 | 
			
		||||
export async function deleteImage(imageId: string) {
 | 
			
		||||
  const image = await prisma.image.findUnique({
 | 
			
		||||
    where: { id: imageId },
 | 
			
		||||
    include: {
 | 
			
		||||
      variants: true,
 | 
			
		||||
      palettes: { include: { items: true } },
 | 
			
		||||
      colors: true,
 | 
			
		||||
      extractColors: true,
 | 
			
		||||
      theme: true,
 | 
			
		||||
      metadata: true,
 | 
			
		||||
      pixels: true,
 | 
			
		||||
      stats: true,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!image) throw new Error("Image not found");
 | 
			
		||||
 | 
			
		||||
  // Delete S3 objects
 | 
			
		||||
  for (const variant of image.variants) {
 | 
			
		||||
    await s3.send(new DeleteObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: variant.s3Key,
 | 
			
		||||
    }));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Delete image variants
 | 
			
		||||
  await prisma.imageVariant.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Delete extract colors
 | 
			
		||||
  await prisma.extractColor.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Delete image colors
 | 
			
		||||
  await prisma.imageColor.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Delete palettes (and items only if no other image uses them)
 | 
			
		||||
  const palettes = await prisma.colorPalette.findMany({
 | 
			
		||||
    where: { images: { some: { id: imageId } } },
 | 
			
		||||
    include: { images: { select: { id: true } }, items: true }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  for (const palette of palettes) {
 | 
			
		||||
    if (palette.images.length === 1 && palette.images[0].id === imageId) {
 | 
			
		||||
      await prisma.colorPaletteItem.deleteMany({ where: { colorPaletteId: palette.id } });
 | 
			
		||||
      await prisma.colorPalette.delete({ where: { id: palette.id } });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Delete metadata-related entries
 | 
			
		||||
  await prisma.imageMetadata.deleteMany({ where: { imageId } });
 | 
			
		||||
  await prisma.imageStats.deleteMany({ where: { imageId } });
 | 
			
		||||
  await prisma.pixelSummary.deleteMany({ where: { imageId } });
 | 
			
		||||
  await prisma.themeSeed.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Finally delete the image
 | 
			
		||||
  await prisma.image.delete({ where: { id: imageId } });
 | 
			
		||||
 | 
			
		||||
  return { success: true };
 | 
			
		||||
}
 | 
			
		||||
@ -1,330 +1,330 @@
 | 
			
		||||
"use server"
 | 
			
		||||
// "use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { s3 } from "@/lib/s3";
 | 
			
		||||
import { imageUploadSchema } from "@/schemas/images/imageSchema";
 | 
			
		||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
			
		||||
import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
 | 
			
		||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
 | 
			
		||||
import { extractColors } from "extract-colors";
 | 
			
		||||
import getPixels from "get-pixels";
 | 
			
		||||
import { NdArray } from "ndarray";
 | 
			
		||||
import { Vibrant } from "node-vibrant/node";
 | 
			
		||||
import path from "path";
 | 
			
		||||
import sharp from "sharp";
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
// import prisma from "@/lib/prisma";
 | 
			
		||||
// import { s3 } from "@/lib/s3";
 | 
			
		||||
// import { imageUploadSchema } from "@/schemas/images/imageSchema";
 | 
			
		||||
// import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
			
		||||
// import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
 | 
			
		||||
// import { PutObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
// import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
 | 
			
		||||
// import { extractColors } from "extract-colors";
 | 
			
		||||
// import getPixels from "get-pixels";
 | 
			
		||||
// import { NdArray } from "ndarray";
 | 
			
		||||
// import { Vibrant } from "node-vibrant/node";
 | 
			
		||||
// import path from "path";
 | 
			
		||||
// import sharp from "sharp";
 | 
			
		||||
// import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
// import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
 | 
			
		||||
  const imageFile = values.file[0];
 | 
			
		||||
  const imageName = values.imageName;
 | 
			
		||||
// export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
 | 
			
		||||
//   const imageFile = values.file[0];
 | 
			
		||||
//   const imageName = values.imageName;
 | 
			
		||||
 | 
			
		||||
  if (!(imageFile instanceof File)) {
 | 
			
		||||
    console.log("No image or invalid type");
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
//   if (!(imageFile instanceof File)) {
 | 
			
		||||
//     console.log("No image or invalid type");
 | 
			
		||||
//     return null;
 | 
			
		||||
//   }
 | 
			
		||||
 | 
			
		||||
  if (!imageName) {
 | 
			
		||||
    console.log("No name for the image provided");
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
//   if (!imageName) {
 | 
			
		||||
//     console.log("No name for the image provided");
 | 
			
		||||
//     return null;
 | 
			
		||||
//   }
 | 
			
		||||
 | 
			
		||||
  const fileName = imageFile.name;
 | 
			
		||||
  const fileType = imageFile.type;
 | 
			
		||||
  const fileSize = imageFile.size;
 | 
			
		||||
  const lastModified = new Date(imageFile.lastModified);
 | 
			
		||||
  const year = lastModified.getUTCFullYear();
 | 
			
		||||
  const month = lastModified.getUTCMonth() + 1;
 | 
			
		||||
//   const fileName = imageFile.name;
 | 
			
		||||
//   const fileType = imageFile.type;
 | 
			
		||||
//   const fileSize = imageFile.size;
 | 
			
		||||
//   const lastModified = new Date(imageFile.lastModified);
 | 
			
		||||
//   const year = lastModified.getUTCFullYear();
 | 
			
		||||
//   const month = lastModified.getUTCMonth() + 1;
 | 
			
		||||
 | 
			
		||||
  const fileKey = uuidv4();
 | 
			
		||||
//   const fileKey = uuidv4();
 | 
			
		||||
 | 
			
		||||
  const arrayBuffer = await imageFile.arrayBuffer();
 | 
			
		||||
  const buffer = Buffer.from(arrayBuffer);
 | 
			
		||||
//   const arrayBuffer = await imageFile.arrayBuffer();
 | 
			
		||||
//   const buffer = Buffer.from(arrayBuffer);
 | 
			
		||||
 | 
			
		||||
  const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
 | 
			
		||||
//   const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
 | 
			
		||||
 | 
			
		||||
  const originalKey = `original/${fileKey}.webp`;
 | 
			
		||||
  const watermarkedKey = `watermarked/${fileKey}.webp`;
 | 
			
		||||
  const resizedKey = `resized/${fileKey}.webp`;
 | 
			
		||||
  const thumbnailKey = `thumbnails/${fileKey}.webp`;
 | 
			
		||||
//   const originalKey = `original/${fileKey}.webp`;
 | 
			
		||||
//   const watermarkedKey = `watermarked/${fileKey}.webp`;
 | 
			
		||||
//   const resizedKey = `resized/${fileKey}.webp`;
 | 
			
		||||
//   const thumbnailKey = `thumbnails/${fileKey}.webp`;
 | 
			
		||||
 | 
			
		||||
  const sharpData = sharp(buffer);
 | 
			
		||||
  const metadata = await sharpData.metadata();
 | 
			
		||||
  const stats = await sharpData.stats();
 | 
			
		||||
//   const sharpData = sharp(buffer);
 | 
			
		||||
//   const metadata = await sharpData.metadata();
 | 
			
		||||
//   const stats = await sharpData.stats();
 | 
			
		||||
 | 
			
		||||
  const palette = await Vibrant.from(buffer).getPalette();
 | 
			
		||||
//   const palette = await Vibrant.from(buffer).getPalette();
 | 
			
		||||
  
 | 
			
		||||
  const vibrantHexes = Object.fromEntries(
 | 
			
		||||
    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 [key, hex];
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
//   const vibrantHexes = Object.fromEntries(
 | 
			
		||||
//     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 [key, hex];
 | 
			
		||||
//     })
 | 
			
		||||
//   );
 | 
			
		||||
 | 
			
		||||
  for (const [type, hex] of Object.entries(vibrantHexes)) {
 | 
			
		||||
    if (!hex) continue;
 | 
			
		||||
    const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
    await prisma.imageColor.create({
 | 
			
		||||
      data: {
 | 
			
		||||
        type,
 | 
			
		||||
        hex,
 | 
			
		||||
        red: r,
 | 
			
		||||
        green: g,
 | 
			
		||||
        blue: b,
 | 
			
		||||
        imageId: image.id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
//   for (const [type, hex] of Object.entries(vibrantHexes)) {
 | 
			
		||||
//     if (!hex) continue;
 | 
			
		||||
//     const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
//     await prisma.imageColor.create({
 | 
			
		||||
//       data: {
 | 
			
		||||
//         type,
 | 
			
		||||
//         hex,
 | 
			
		||||
//         red: r,
 | 
			
		||||
//         green: g,
 | 
			
		||||
//         blue: b,
 | 
			
		||||
//         imageId: image.id,
 | 
			
		||||
//       },
 | 
			
		||||
//     });
 | 
			
		||||
//   }
 | 
			
		||||
 | 
			
		||||
  const seedHex = 
 | 
			
		||||
    vibrantHexes.Vibrant ??
 | 
			
		||||
    vibrantHexes.Muted ??
 | 
			
		||||
    vibrantHexes.DarkVibrant ??
 | 
			
		||||
    vibrantHexes.DarkMuted ??
 | 
			
		||||
    vibrantHexes.LightVibrant ??
 | 
			
		||||
    vibrantHexes.LightMuted ??
 | 
			
		||||
    "#dfffff";
 | 
			
		||||
//   const seedHex = 
 | 
			
		||||
//     vibrantHexes.Vibrant ??
 | 
			
		||||
//     vibrantHexes.Muted ??
 | 
			
		||||
//     vibrantHexes.DarkVibrant ??
 | 
			
		||||
//     vibrantHexes.DarkMuted ??
 | 
			
		||||
//     vibrantHexes.LightVibrant ??
 | 
			
		||||
//     vibrantHexes.LightMuted ??
 | 
			
		||||
//     "#dfffff";
 | 
			
		||||
 | 
			
		||||
  const theme = themeFromSourceColor(argbFromHex(seedHex));
 | 
			
		||||
  const primaryTones = extractPaletteTones(theme.palettes.primary);
 | 
			
		||||
  const secondaryTones = extractPaletteTones(theme.palettes.secondary);
 | 
			
		||||
  const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
 | 
			
		||||
  const neutralTones = extractPaletteTones(theme.palettes.neutral);
 | 
			
		||||
  const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
 | 
			
		||||
  const errorTones = extractPaletteTones(theme.palettes.error);
 | 
			
		||||
//   const theme = themeFromSourceColor(argbFromHex(seedHex));
 | 
			
		||||
//   const primaryTones = extractPaletteTones(theme.palettes.primary);
 | 
			
		||||
//   const secondaryTones = extractPaletteTones(theme.palettes.secondary);
 | 
			
		||||
//   const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
 | 
			
		||||
//   const neutralTones = extractPaletteTones(theme.palettes.neutral);
 | 
			
		||||
//   const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
 | 
			
		||||
//   const errorTones = extractPaletteTones(theme.palettes.error);
 | 
			
		||||
 | 
			
		||||
  const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
 | 
			
		||||
    getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
 | 
			
		||||
      if (err) reject(err);
 | 
			
		||||
      else resolve(pixels);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
//   const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
 | 
			
		||||
//     getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
 | 
			
		||||
//       if (err) reject(err);
 | 
			
		||||
//       else resolve(pixels);
 | 
			
		||||
//     });
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  const extracted = await extractColors({
 | 
			
		||||
    data: Array.from(pixels.data),
 | 
			
		||||
    width: pixels.shape[0],
 | 
			
		||||
    height: pixels.shape[1]
 | 
			
		||||
  });
 | 
			
		||||
//   const extracted = await extractColors({
 | 
			
		||||
//     data: Array.from(pixels.data),
 | 
			
		||||
//     width: pixels.shape[0],
 | 
			
		||||
//     height: pixels.shape[1]
 | 
			
		||||
//   });
 | 
			
		||||
  
 | 
			
		||||
  //--- Original file
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: originalKey,
 | 
			
		||||
      Body: buffer,
 | 
			
		||||
      ContentType: "image/" + metadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Watermarked file
 | 
			
		||||
  const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
 | 
			
		||||
  const watermarkWidth = Math.round(metadata.width * 0.25);
 | 
			
		||||
  const watermarkBuffer = await sharp(watermarkPath)
 | 
			
		||||
    .resize({ width: watermarkWidth })
 | 
			
		||||
    .png()
 | 
			
		||||
    .toBuffer();
 | 
			
		||||
  const watermarkedBuffer = await sharp(buffer)
 | 
			
		||||
    .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer()
 | 
			
		||||
  const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: watermarkedKey,
 | 
			
		||||
      Body: watermarkedBuffer,
 | 
			
		||||
      ContentType: "image/" + watermarkedMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Resized file
 | 
			
		||||
  const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
 | 
			
		||||
  const resizedBuffer = await sharp(watermarkedBuffer)
 | 
			
		||||
    .resize({ width: resizedWidth, withoutEnlargement: true })
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer();
 | 
			
		||||
  const resizedMetadata = await sharp(resizedBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: resizedKey,
 | 
			
		||||
      Body: resizedBuffer,
 | 
			
		||||
      ContentType: "image/" + resizedMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Thumbnail file
 | 
			
		||||
  const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
 | 
			
		||||
  const thumbnailBuffer = await sharp(watermarkedBuffer)
 | 
			
		||||
    .resize({ width: thumbnailWidth, withoutEnlargement: true })
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer();
 | 
			
		||||
  const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "felliesartapp",
 | 
			
		||||
      Key: thumbnailKey,
 | 
			
		||||
      Body: thumbnailBuffer,
 | 
			
		||||
      ContentType: "image/" + thumbnailMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
//   //--- Original file
 | 
			
		||||
//   await s3.send(
 | 
			
		||||
//     new PutObjectCommand({
 | 
			
		||||
//       Bucket: "felliesartapp",
 | 
			
		||||
//       Key: originalKey,
 | 
			
		||||
//       Body: buffer,
 | 
			
		||||
//       ContentType: "image/" + metadata.format,
 | 
			
		||||
//     })
 | 
			
		||||
//   );
 | 
			
		||||
//   //--- Watermarked file
 | 
			
		||||
//   const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
 | 
			
		||||
//   const watermarkWidth = Math.round(metadata.width * 0.25);
 | 
			
		||||
//   const watermarkBuffer = await sharp(watermarkPath)
 | 
			
		||||
//     .resize({ width: watermarkWidth })
 | 
			
		||||
//     .png()
 | 
			
		||||
//     .toBuffer();
 | 
			
		||||
//   const watermarkedBuffer = await sharp(buffer)
 | 
			
		||||
//     .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
 | 
			
		||||
//     .toFormat('webp')
 | 
			
		||||
//     .toBuffer()
 | 
			
		||||
//   const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
 | 
			
		||||
//   await s3.send(
 | 
			
		||||
//     new PutObjectCommand({
 | 
			
		||||
//       Bucket: "felliesartapp",
 | 
			
		||||
//       Key: watermarkedKey,
 | 
			
		||||
//       Body: watermarkedBuffer,
 | 
			
		||||
//       ContentType: "image/" + watermarkedMetadata.format,
 | 
			
		||||
//     })
 | 
			
		||||
//   );
 | 
			
		||||
//   //--- Resized file
 | 
			
		||||
//   const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
 | 
			
		||||
//   const resizedBuffer = await sharp(watermarkedBuffer)
 | 
			
		||||
//     .resize({ width: resizedWidth, withoutEnlargement: true })
 | 
			
		||||
//     .toFormat('webp')
 | 
			
		||||
//     .toBuffer();
 | 
			
		||||
//   const resizedMetadata = await sharp(resizedBuffer).metadata();
 | 
			
		||||
//   await s3.send(
 | 
			
		||||
//     new PutObjectCommand({
 | 
			
		||||
//       Bucket: "felliesartapp",
 | 
			
		||||
//       Key: resizedKey,
 | 
			
		||||
//       Body: resizedBuffer,
 | 
			
		||||
//       ContentType: "image/" + resizedMetadata.format,
 | 
			
		||||
//     })
 | 
			
		||||
//   );
 | 
			
		||||
//   //--- Thumbnail file
 | 
			
		||||
//   const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
 | 
			
		||||
//   const thumbnailBuffer = await sharp(watermarkedBuffer)
 | 
			
		||||
//     .resize({ width: thumbnailWidth, withoutEnlargement: true })
 | 
			
		||||
//     .toFormat('webp')
 | 
			
		||||
//     .toBuffer();
 | 
			
		||||
//   const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
 | 
			
		||||
//   await s3.send(
 | 
			
		||||
//     new PutObjectCommand({
 | 
			
		||||
//       Bucket: "felliesartapp",
 | 
			
		||||
//       Key: thumbnailKey,
 | 
			
		||||
//       Body: thumbnailBuffer,
 | 
			
		||||
//       ContentType: "image/" + thumbnailMetadata.format,
 | 
			
		||||
//     })
 | 
			
		||||
//   );
 | 
			
		||||
 | 
			
		||||
  const image = await prisma.image.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      imageName,
 | 
			
		||||
      fileKey,
 | 
			
		||||
      originalFile: fileName,
 | 
			
		||||
      uploadDate: new Date(),
 | 
			
		||||
//   const image = await prisma.image.create({
 | 
			
		||||
//     data: {
 | 
			
		||||
//       imageName,
 | 
			
		||||
//       fileKey,
 | 
			
		||||
//       originalFile: fileName,
 | 
			
		||||
//       uploadDate: new Date(),
 | 
			
		||||
 | 
			
		||||
      creationDate: lastModified,
 | 
			
		||||
      creationMonth: month,
 | 
			
		||||
      creationYear: year,
 | 
			
		||||
      imageData: imageDataUrl,
 | 
			
		||||
      fileType: fileType,
 | 
			
		||||
      fileSize: fileSize,
 | 
			
		||||
      altText: "",
 | 
			
		||||
      description: "",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
//       creationDate: lastModified,
 | 
			
		||||
//       creationMonth: month,
 | 
			
		||||
//       creationYear: year,
 | 
			
		||||
//       imageData: imageDataUrl,
 | 
			
		||||
//       fileType: fileType,
 | 
			
		||||
//       fileSize: fileSize,
 | 
			
		||||
//       altText: "",
 | 
			
		||||
//       description: "",
 | 
			
		||||
//     },
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  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.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.imageStats.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      imageId: image.id,
 | 
			
		||||
      isOpaque: stats.isOpaque,
 | 
			
		||||
      entropy: stats.entropy,
 | 
			
		||||
      sharpness: stats.sharpness,
 | 
			
		||||
      dominantR: stats.dominant.r,
 | 
			
		||||
      dominantG: stats.dominant.g,
 | 
			
		||||
      dominantB: stats.dominant.b,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
//   await prisma.imageStats.create({
 | 
			
		||||
//     data: {
 | 
			
		||||
//       imageId: image.id,
 | 
			
		||||
//       isOpaque: stats.isOpaque,
 | 
			
		||||
//       entropy: stats.entropy,
 | 
			
		||||
//       sharpness: stats.sharpness,
 | 
			
		||||
//       dominantR: stats.dominant.r,
 | 
			
		||||
//       dominantG: stats.dominant.g,
 | 
			
		||||
//       dominantB: stats.dominant.b,
 | 
			
		||||
//     },
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  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: watermarkedKey,
 | 
			
		||||
        type: "watermarked",
 | 
			
		||||
        height: watermarkedMetadata.height,
 | 
			
		||||
        width: watermarkedMetadata.width,
 | 
			
		||||
        fileExtension: watermarkedMetadata.format,
 | 
			
		||||
        mimeType: "image/" + watermarkedMetadata.format,
 | 
			
		||||
        sizeBytes: watermarkedMetadata.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
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
//   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: watermarkedKey,
 | 
			
		||||
//         type: "watermarked",
 | 
			
		||||
//         height: watermarkedMetadata.height,
 | 
			
		||||
//         width: watermarkedMetadata.width,
 | 
			
		||||
//         fileExtension: watermarkedMetadata.format,
 | 
			
		||||
//         mimeType: "image/" + watermarkedMetadata.format,
 | 
			
		||||
//         sizeBytes: watermarkedMetadata.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
 | 
			
		||||
//       }
 | 
			
		||||
//     ],
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  await upsertPalettes(primaryTones, image.id, "primary");
 | 
			
		||||
  await upsertPalettes(secondaryTones, image.id, "secondary");
 | 
			
		||||
  await upsertPalettes(tertiaryTones, image.id, "tertiary");
 | 
			
		||||
  await upsertPalettes(neutralTones, image.id, "neutral");
 | 
			
		||||
  await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
 | 
			
		||||
  await upsertPalettes(errorTones, image.id, "error");
 | 
			
		||||
//   await upsertPalettes(primaryTones, image.id, "primary");
 | 
			
		||||
//   await upsertPalettes(secondaryTones, image.id, "secondary");
 | 
			
		||||
//   await upsertPalettes(tertiaryTones, image.id, "tertiary");
 | 
			
		||||
//   await upsertPalettes(neutralTones, image.id, "neutral");
 | 
			
		||||
//   await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
 | 
			
		||||
//   await upsertPalettes(errorTones, image.id, "error");
 | 
			
		||||
 | 
			
		||||
  for (const [type, hex] of Object.entries(vibrantHexes)) {
 | 
			
		||||
    if (!hex) continue;
 | 
			
		||||
    const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
    await prisma.imageColor.create({
 | 
			
		||||
      data: {
 | 
			
		||||
        type,
 | 
			
		||||
        hex,
 | 
			
		||||
        red: r,
 | 
			
		||||
        green: g,
 | 
			
		||||
        blue: b,
 | 
			
		||||
        imageId: image.id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
//   for (const [type, hex] of Object.entries(vibrantHexes)) {
 | 
			
		||||
//     if (!hex) continue;
 | 
			
		||||
//     const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
//     await prisma.imageColor.create({
 | 
			
		||||
//       data: {
 | 
			
		||||
//         type,
 | 
			
		||||
//         hex,
 | 
			
		||||
//         red: r,
 | 
			
		||||
//         green: g,
 | 
			
		||||
//         blue: b,
 | 
			
		||||
//         imageId: image.id,
 | 
			
		||||
//       },
 | 
			
		||||
//     });
 | 
			
		||||
//   }
 | 
			
		||||
 | 
			
		||||
  for (const c of extracted) {
 | 
			
		||||
    await prisma.extractColor.create({
 | 
			
		||||
      data: {
 | 
			
		||||
        hex: c.hex,
 | 
			
		||||
        red: c.red,
 | 
			
		||||
        green: c.green,
 | 
			
		||||
        blue: c.blue,
 | 
			
		||||
        hue: c.hue,
 | 
			
		||||
        saturation: c.saturation,
 | 
			
		||||
        // value: c.value,
 | 
			
		||||
        area: c.area,
 | 
			
		||||
        // isLight: c.isLight,
 | 
			
		||||
        imageId: image.id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
//   for (const c of extracted) {
 | 
			
		||||
//     await prisma.extractColor.create({
 | 
			
		||||
//       data: {
 | 
			
		||||
//         hex: c.hex,
 | 
			
		||||
//         red: c.red,
 | 
			
		||||
//         green: c.green,
 | 
			
		||||
//         blue: c.blue,
 | 
			
		||||
//         hue: c.hue,
 | 
			
		||||
//         saturation: c.saturation,
 | 
			
		||||
//         // value: c.value,
 | 
			
		||||
//         area: c.area,
 | 
			
		||||
//         // isLight: c.isLight,
 | 
			
		||||
//         imageId: image.id,
 | 
			
		||||
//       },
 | 
			
		||||
//     });
 | 
			
		||||
//   }
 | 
			
		||||
 | 
			
		||||
  await prisma.themeSeed.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      seedHex,
 | 
			
		||||
      imageId: image.id,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
//   await prisma.themeSeed.create({
 | 
			
		||||
//     data: {
 | 
			
		||||
//       seedHex,
 | 
			
		||||
//       imageId: image.id,
 | 
			
		||||
//     },
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  await prisma.pixelSummary.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      width: pixels.shape[0],
 | 
			
		||||
      height: pixels.shape[1],
 | 
			
		||||
      channels: pixels.shape[2],
 | 
			
		||||
      imageId: image.id,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
//   await prisma.pixelSummary.create({
 | 
			
		||||
//     data: {
 | 
			
		||||
//       width: pixels.shape[0],
 | 
			
		||||
//       height: pixels.shape[1],
 | 
			
		||||
//       channels: pixels.shape[2],
 | 
			
		||||
//       imageId: image.id,
 | 
			
		||||
//     },
 | 
			
		||||
//   });
 | 
			
		||||
 | 
			
		||||
  return image
 | 
			
		||||
  // return await prisma.gallery.create({
 | 
			
		||||
  //   data: {
 | 
			
		||||
  //     name: values.name,
 | 
			
		||||
  //     slug: values.slug,
 | 
			
		||||
  //     description: values.description,
 | 
			
		||||
  //   }
 | 
			
		||||
  // })
 | 
			
		||||
}
 | 
			
		||||
//   return image
 | 
			
		||||
//   // return await prisma.gallery.create({
 | 
			
		||||
//   //   data: {
 | 
			
		||||
//   //     name: values.name,
 | 
			
		||||
//   //     slug: values.slug,
 | 
			
		||||
//   //     description: values.description,
 | 
			
		||||
//   //   }
 | 
			
		||||
//   // })
 | 
			
		||||
// }
 | 
			
		||||
@ -2,6 +2,7 @@
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
 | 
			
		||||
import { generateExtractColorName } from "@/utils/uploadHelper";
 | 
			
		||||
import { extractColors } from "extract-colors";
 | 
			
		||||
import getPixels from "get-pixels";
 | 
			
		||||
import { NdArray } from "ndarray";
 | 
			
		||||
@ -34,21 +35,35 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  for (const c of extracted) {
 | 
			
		||||
    await prisma.extractColor.create({
 | 
			
		||||
    const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area);
 | 
			
		||||
 | 
			
		||||
    await prisma.image.update({
 | 
			
		||||
      where: { id: imageId },
 | 
			
		||||
      data: {
 | 
			
		||||
        hex: c.hex,
 | 
			
		||||
        red: c.red,
 | 
			
		||||
        green: c.green,
 | 
			
		||||
        blue: c.blue,
 | 
			
		||||
        hue: c.hue,
 | 
			
		||||
        saturation: c.saturation,
 | 
			
		||||
        area: c.area,
 | 
			
		||||
        imageId: imageId,
 | 
			
		||||
        extractColors: {
 | 
			
		||||
          connectOrCreate: {
 | 
			
		||||
            where: { name },
 | 
			
		||||
            create: {
 | 
			
		||||
              name,
 | 
			
		||||
              hex: c.hex,
 | 
			
		||||
              red: c.red,
 | 
			
		||||
              green: c.green,
 | 
			
		||||
              blue: c.blue,
 | 
			
		||||
              hue: c.hue,
 | 
			
		||||
              saturation: c.saturation,
 | 
			
		||||
              area: c.area,
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return await prisma.extractColor.findMany({
 | 
			
		||||
    where: { imageId: imageId }
 | 
			
		||||
    where: {
 | 
			
		||||
      images: {
 | 
			
		||||
        some: { id: imageId },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
			
		||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
 | 
			
		||||
import { rgbToHex } from "@/utils/uploadHelper";
 | 
			
		||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
 | 
			
		||||
import { Vibrant } from "node-vibrant/node";
 | 
			
		||||
 | 
			
		||||
export async function generateImageColors(imageId: string, fileKey: string) {
 | 
			
		||||
@ -22,19 +22,33 @@ export async function generateImageColors(imageId: string, fileKey: string) {
 | 
			
		||||
  for (const [type, hex] of Object.entries(vibrantHexes)) {
 | 
			
		||||
    if (!hex) continue;
 | 
			
		||||
    const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
    await prisma.imageColor.create({
 | 
			
		||||
    const name = generateColorName(hex);
 | 
			
		||||
 | 
			
		||||
    await prisma.image.update({
 | 
			
		||||
      where: { id: imageId },
 | 
			
		||||
      data: {
 | 
			
		||||
        type,
 | 
			
		||||
        hex,
 | 
			
		||||
        red: r,
 | 
			
		||||
        green: g,
 | 
			
		||||
        blue: b,
 | 
			
		||||
        imageId: imageId,
 | 
			
		||||
        colors: {
 | 
			
		||||
          connectOrCreate: {
 | 
			
		||||
            where: { name: name },
 | 
			
		||||
            create: {
 | 
			
		||||
              name: name,
 | 
			
		||||
              type: type,
 | 
			
		||||
              hex: hex,
 | 
			
		||||
              red: r,
 | 
			
		||||
              green: g,
 | 
			
		||||
              blue: b,
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return await prisma.imageColor.findMany({
 | 
			
		||||
    where: { imageId: imageId }
 | 
			
		||||
    where: {
 | 
			
		||||
      images: {
 | 
			
		||||
        some: { id: imageId },
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/actions/tags/createTag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/actions/tags/createTag.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { tagSchema } from "@/schemas/tags/tagSchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function createTag(values: z.infer<typeof tagSchema>) {
 | 
			
		||||
  return await prisma.tag.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,
 | 
			
		||||
      description: values.description
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/actions/tags/deleteTag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/actions/tags/deleteTag.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function deleteTag(id: string) {
 | 
			
		||||
  await prisma.tag.delete({ where: { id } });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/actions/tags/updateTag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/actions/tags/updateTag.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { tagSchema } from "@/schemas/tags/tagSchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function updateTag(
 | 
			
		||||
  values: z.infer<typeof tagSchema>, 
 | 
			
		||||
  id: string
 | 
			
		||||
) {
 | 
			
		||||
  return await prisma.tag.update({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: id
 | 
			
		||||
    },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,  
 | 
			
		||||
      description: values.description,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								src/app/categories/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/categories/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import EditCategoryForm from "@/components/categories/edit/EditCategoryForm";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function CategoriesEditPage({ params }: { params: { id: string } }) {
 | 
			
		||||
  const { id } = await params;
 | 
			
		||||
 | 
			
		||||
  const cat = await prisma.category.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">Edit category</h1>
 | 
			
		||||
      {cat ? <EditCategoryForm category={cat} /> : 'Category not found...'}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/categories/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/categories/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import CreateCategoryForm from "@/components/categories/new/CreateCategoryForm";
 | 
			
		||||
 | 
			
		||||
export default async function CategoriesNewPage() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">New category</h1>
 | 
			
		||||
      <CreateCategoryForm />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/categories/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/categories/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
import ListCategories from "@/components/categories/list/ListCategories";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { PlusCircleIcon } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default async function CategoriesPage() {
 | 
			
		||||
  const categories = await prisma.category.findMany(
 | 
			
		||||
    {
 | 
			
		||||
      orderBy: { createdAt: "asc" }
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <div className="flex gap-4 justify-between">
 | 
			
		||||
        <h1 className="text-2xl font-bold mb-4">Categories</h1>
 | 
			
		||||
        <Link href="/categories/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" /> Add new Category
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
      {categories.length > 0 ? <ListCategories categories={categories} /> : <p className="text-muted-foreground italic">No categories found.</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,3 +1,4 @@
 | 
			
		||||
import DeleteImageButton from "@/components/images/edit/DeleteImageButton";
 | 
			
		||||
import EditImageForm from "@/components/images/edit/EditImageForm";
 | 
			
		||||
import ExtractColors from "@/components/images/edit/ExtractColors";
 | 
			
		||||
import ImageColors from "@/components/images/edit/ImageColors";
 | 
			
		||||
@ -26,19 +27,26 @@ export default async function ImagesEditPage({ params }: { params: { id: string
 | 
			
		||||
        include: {
 | 
			
		||||
          items: true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      },
 | 
			
		||||
      tags: true,
 | 
			
		||||
      categories: true
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
 | 
			
		||||
  const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" } });
 | 
			
		||||
  const tags = await prisma.tag.findMany({ orderBy: { createdAt: "asc" } });
 | 
			
		||||
  const categories = await prisma.category.findMany({ orderBy: { createdAt: "asc" } });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">Edit image</h1>
 | 
			
		||||
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
 | 
			
		||||
        <div>
 | 
			
		||||
          {image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'}
 | 
			
		||||
          {image ? <EditImageForm image={image} artists={artists} albums={albums} tags={tags} categories={categories} /> : 'Image not found...'}
 | 
			
		||||
          <div className="mt-6">
 | 
			
		||||
            {image && <DeleteImageButton imageId={image.id} />}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div>
 | 
			
		||||
            {image && <ImageVariants variants={image.variants} />}
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								src/app/tags/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/app/tags/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
import EditTagForm from "@/components/tags/edit/EditTagForm";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function TagsEditPage({ params }: { params: { id: string } }) {
 | 
			
		||||
  const { id } = await params;
 | 
			
		||||
 | 
			
		||||
  const tag = await prisma.tag.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">Edit tag</h1>
 | 
			
		||||
      {tag ? <EditTagForm tag={tag} /> : 'Tag not found...'}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/tags/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/tags/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import CreateTagForm from "@/components/tags/new/CreateTagForm";
 | 
			
		||||
 | 
			
		||||
export default async function TagsNewPage() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">New tag</h1>
 | 
			
		||||
      <CreateTagForm />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/app/tags/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/tags/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
import ListTags from "@/components/tags/list/ListTags";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { PlusCircleIcon } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default async function TagsPage() {
 | 
			
		||||
  const tags = await prisma.tag.findMany(
 | 
			
		||||
    {
 | 
			
		||||
      orderBy: { createdAt: "asc" }
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <div className="flex gap-4 justify-between">
 | 
			
		||||
        <h1 className="text-2xl font-bold mb-4">Tags</h1>
 | 
			
		||||
        <Link href="/tags/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" /> Add new Tag
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
      {tags.length > 0 ? <ListTags tags={tags} /> : <p className="text-muted-foreground italic">No tags found.</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/categories/edit/EditCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/categories/edit/EditCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { deleteCategory } from "@/actions/categories/deleteCategory";
 | 
			
		||||
import { updateCategory } from "@/actions/categories/updateCategory";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Category } from "@/generated/prisma";
 | 
			
		||||
import { categorySchema } from "@/schemas/categories/categorySchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function EditCategoryForm({ category }: { category: Category }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof categorySchema>>({
 | 
			
		||||
    resolver: zodResolver(categorySchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: category.name,
 | 
			
		||||
      description: category.description || "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof categorySchema>) {
 | 
			
		||||
    const updatedCategory = await updateCategory(values, category.id)
 | 
			
		||||
    if (updatedCategory) {
 | 
			
		||||
      toast.success("Category updated")
 | 
			
		||||
      router.push(`/categories`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-8">
 | 
			
		||||
      <Form {...form}>
 | 
			
		||||
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="name"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Category name</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Category name" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  This is your public display name.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="description"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Category description (optional)</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Category description" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  Description of the Category.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <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>
 | 
			
		||||
        <Button
 | 
			
		||||
          type="button"
 | 
			
		||||
          variant="destructive"
 | 
			
		||||
          onClick={async () => {
 | 
			
		||||
            await deleteCategory(category.id);
 | 
			
		||||
            toast.success("Category deleted");
 | 
			
		||||
            router.push("/categories");
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          Delete Category
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/categories/list/ListCategories.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/categories/list/ListCategories.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { Category } from "@/generated/prisma";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function ListCategories({ categories }: { categories: Category[] }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
      {categories.map((cat) => (
 | 
			
		||||
        <Link href={`/categories/edit/${cat.id}`} key={cat.id}>
 | 
			
		||||
          <Card className="overflow-hidden">
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle className="text-base truncate">{cat.name}</CardTitle>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent>
 | 
			
		||||
              {cat.description && <p className="text-sm text-muted-foreground">{cat.description}</p>}
 | 
			
		||||
            </CardContent>
 | 
			
		||||
            <CardFooter>
 | 
			
		||||
            </CardFooter>
 | 
			
		||||
          </Card>
 | 
			
		||||
        </Link>
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										74
									
								
								src/components/categories/new/CreateCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/components/categories/new/CreateCategoryForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,74 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { createCategory } from "@/actions/categories/createCategory";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { categorySchema } from "@/schemas/categories/categorySchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function CreateCategoryForm() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof categorySchema>>({
 | 
			
		||||
    resolver: zodResolver(categorySchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: "",
 | 
			
		||||
      description: ""
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof categorySchema>) {
 | 
			
		||||
    const cat = await createCategory(values)
 | 
			
		||||
    if (cat) {
 | 
			
		||||
      toast.success("Category created")
 | 
			
		||||
      router.push(`/categories`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form {...form}>
 | 
			
		||||
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="name"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Category name</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Category name" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                This is your public display name.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="description"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Category description</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Category description" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                Description of the category.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -27,6 +27,16 @@ export default function TopNav() {
 | 
			
		||||
            <Link href="/artists">Artists</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/categories">Categories</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/tags">Tags</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/images">Images</Link>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										26
									
								
								src/components/images/edit/DeleteImageButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/components/images/edit/DeleteImageButton.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { deleteImage } from "@/actions/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("/images"); // redirect to image list or gallery
 | 
			
		||||
      } else {
 | 
			
		||||
        alert("Failed to delete image.");
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button variant="destructive" onClick={handleDelete}>
 | 
			
		||||
      Delete Image
 | 
			
		||||
    </Button>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -5,10 +5,11 @@ import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Calendar } from "@/components/ui/calendar";
 | 
			
		||||
import { Form, FormControl, 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 { Textarea } from "@/components/ui/textarea";
 | 
			
		||||
import { Album, Artist, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, ThemeSeed } from "@/generated/prisma";
 | 
			
		||||
import { Album, Artist, Category, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, Tag, ThemeSeed } from "@/generated/prisma";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { imageSchema } from "@/schemas/images/imageSchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
@ -28,6 +29,8 @@ type ImageWithItems = Image & {
 | 
			
		||||
  stats: ImageStats[],
 | 
			
		||||
  theme: ThemeSeed[],
 | 
			
		||||
  variants: ImageVariant[],
 | 
			
		||||
  tags: Tag[],
 | 
			
		||||
  categories: Category[],
 | 
			
		||||
  palettes: (
 | 
			
		||||
    ColorPalette & {
 | 
			
		||||
      items: ColorPaletteItem[]
 | 
			
		||||
@ -35,7 +38,14 @@ type ImageWithItems = Image & {
 | 
			
		||||
  )[]
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function EditImageForm({ image, artists, albums }: { image: ImageWithItems, artists: Artist[], albums: Album[] }) {
 | 
			
		||||
export default function EditImageForm({ image, artists, albums, tags, categories }:
 | 
			
		||||
  {
 | 
			
		||||
    image: ImageWithItems,
 | 
			
		||||
    artists: Artist[],
 | 
			
		||||
    albums: Album[],
 | 
			
		||||
    tags: Tag[],
 | 
			
		||||
    categories: Category[]
 | 
			
		||||
  }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof imageSchema>>({
 | 
			
		||||
    resolver: zodResolver(imageSchema),
 | 
			
		||||
@ -56,6 +66,8 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
 | 
			
		||||
 | 
			
		||||
      artistId: image.artist?.id || undefined,
 | 
			
		||||
      albumId: image.album?.id || undefined,
 | 
			
		||||
      tagIds: image.tags?.map(tag => tag.id) ?? [],
 | 
			
		||||
      categoryIds: image.categories?.map(cat => cat.id) ?? [],
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@ -344,12 +356,76 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="tagIds"
 | 
			
		||||
            render={({ field }) => {
 | 
			
		||||
              const selectedOptions = tags
 | 
			
		||||
                .filter(tag => field.value?.includes(tag.id))
 | 
			
		||||
                .map(tag => ({ label: tag.name, value: tag.id }));
 | 
			
		||||
              return (
 | 
			
		||||
                <FormItem>
 | 
			
		||||
                  <FormLabel>Tags</FormLabel>
 | 
			
		||||
                  <FormControl>
 | 
			
		||||
                    <MultipleSelector
 | 
			
		||||
                      defaultOptions={tags.map(tag => ({
 | 
			
		||||
                        label: tag.name,
 | 
			
		||||
                        value: tag.id,
 | 
			
		||||
                      }))}
 | 
			
		||||
                      placeholder="Select tags"
 | 
			
		||||
                      hidePlaceholderWhenSelected
 | 
			
		||||
                      selectFirstItem
 | 
			
		||||
                      value={selectedOptions}
 | 
			
		||||
                      onChange={(options) => {
 | 
			
		||||
                        const ids = options.map(option => option.value);
 | 
			
		||||
                        field.onChange(ids);
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </FormControl>
 | 
			
		||||
                  <FormMessage />
 | 
			
		||||
                </FormItem>
 | 
			
		||||
              )
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="categoryIds"
 | 
			
		||||
            render={({ field }) => {
 | 
			
		||||
              const selectedOptions = categories
 | 
			
		||||
                .filter(cat => field.value?.includes(cat.id))
 | 
			
		||||
                .map(cat => ({ label: cat.name, value: cat.id }));
 | 
			
		||||
              return (
 | 
			
		||||
                <FormItem>
 | 
			
		||||
                  <FormLabel>Categories</FormLabel>
 | 
			
		||||
                  <FormControl>
 | 
			
		||||
                    <MultipleSelector
 | 
			
		||||
                      defaultOptions={categories.map(cat => ({
 | 
			
		||||
                        label: cat.name,
 | 
			
		||||
                        value: cat.id,
 | 
			
		||||
                      }))}
 | 
			
		||||
                      placeholder="Select categories"
 | 
			
		||||
                      hidePlaceholderWhenSelected
 | 
			
		||||
                      selectFirstItem
 | 
			
		||||
                      value={selectedOptions}
 | 
			
		||||
                      onChange={(options) => {
 | 
			
		||||
                        const ids = options.map(option => option.value);
 | 
			
		||||
                        field.onChange(ids);
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </FormControl>
 | 
			
		||||
                  <FormMessage />
 | 
			
		||||
                </FormItem>
 | 
			
		||||
              )
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <div className="flex flex-col gap-4">
 | 
			
		||||
            <Button type="submit">Submit</Button>
 | 
			
		||||
            <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      </Form>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/components/tags/edit/EditTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/components/tags/edit/EditTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { deleteTag } from "@/actions/tags/deleteTag";
 | 
			
		||||
import { updateTag } from "@/actions/tags/updateTag";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Tag } from "@/generated/prisma";
 | 
			
		||||
import { tagSchema } from "@/schemas/tags/tagSchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function EditTagForm({ tag }: { tag: Tag }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof tagSchema>>({
 | 
			
		||||
    resolver: zodResolver(tagSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: tag.name,
 | 
			
		||||
      description: tag.description || "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof tagSchema>) {
 | 
			
		||||
    const updatedTag = await updateTag(values, tag.id)
 | 
			
		||||
    if (updatedTag) {
 | 
			
		||||
      toast.success("Tag updated")
 | 
			
		||||
      router.push(`/tags`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-8">
 | 
			
		||||
      <Form {...form}>
 | 
			
		||||
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="name"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Tag name</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Tag name" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  This is your public display name.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="description"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Tag description (optional)</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Tag description" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  Description of the Category.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <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>
 | 
			
		||||
        <Button
 | 
			
		||||
          type="button"
 | 
			
		||||
          variant="destructive"
 | 
			
		||||
          onClick={async () => {
 | 
			
		||||
            await deleteTag(tag.id);
 | 
			
		||||
            toast.success("Tag deleted");
 | 
			
		||||
            router.push("/tags");
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          Delete Tag
 | 
			
		||||
        </Button>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/tags/list/ListTags.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/tags/list/ListTags.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { Tag } from "@/generated/prisma";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function ListTags({ tags }: { tags: Tag[] }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
 | 
			
		||||
      {tags.map((tag) => (
 | 
			
		||||
        <Link href={`/tags/edit/${tag.id}`} key={tag.id}>
 | 
			
		||||
          <Card className="overflow-hidden">
 | 
			
		||||
            <CardHeader>
 | 
			
		||||
              <CardTitle className="text-base truncate">{tag.name}</CardTitle>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent>
 | 
			
		||||
              {tag.description && <p className="text-sm text-muted-foreground">{tag.description}</p>}
 | 
			
		||||
            </CardContent>
 | 
			
		||||
            <CardFooter>
 | 
			
		||||
            </CardFooter>
 | 
			
		||||
          </Card>
 | 
			
		||||
        </Link>
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										74
									
								
								src/components/tags/new/CreateTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/components/tags/new/CreateTagForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,74 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { createTag } from "@/actions/tags/createTag";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { tagSchema } from "@/schemas/tags/tagSchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function CreateTagForm() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof tagSchema>>({
 | 
			
		||||
    resolver: zodResolver(tagSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: "",
 | 
			
		||||
      description: ""
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof tagSchema>) {
 | 
			
		||||
    const tag = await createTag(values)
 | 
			
		||||
    if (tag) {
 | 
			
		||||
      toast.success("Tag created")
 | 
			
		||||
      router.push(`/tags`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form {...form}>
 | 
			
		||||
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="name"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Tag name</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Tag name" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                This is your public display name.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="description"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Tag description</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Tag description" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                Description of the category.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,46 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import { Slot } from "@radix-ui/react-slot"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const badgeVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
 | 
			
		||||
        outline:
 | 
			
		||||
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
function Badge({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span"> &
 | 
			
		||||
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
 | 
			
		||||
  const Comp = asChild ? Slot : "span"
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Comp
 | 
			
		||||
      data-slot="badge"
 | 
			
		||||
      className={cn(badgeVariants({ variant }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Badge, badgeVariants }
 | 
			
		||||
							
								
								
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,184 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import { Command as CommandPrimitive } from "cmdk"
 | 
			
		||||
import { SearchIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogDescription,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
} from "@/components/ui/dialog"
 | 
			
		||||
 | 
			
		||||
function Command({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive
 | 
			
		||||
      data-slot="command"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandDialog({
 | 
			
		||||
  title = "Command Palette",
 | 
			
		||||
  description = "Search for a command to run...",
 | 
			
		||||
  children,
 | 
			
		||||
  className,
 | 
			
		||||
  showCloseButton = true,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof Dialog> & {
 | 
			
		||||
  title?: string
 | 
			
		||||
  description?: string
 | 
			
		||||
  className?: string
 | 
			
		||||
  showCloseButton?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog {...props}>
 | 
			
		||||
      <DialogHeader className="sr-only">
 | 
			
		||||
        <DialogTitle>{title}</DialogTitle>
 | 
			
		||||
        <DialogDescription>{description}</DialogDescription>
 | 
			
		||||
      </DialogHeader>
 | 
			
		||||
      <DialogContent
 | 
			
		||||
        className={cn("overflow-hidden p-0", className)}
 | 
			
		||||
        showCloseButton={showCloseButton}
 | 
			
		||||
      >
 | 
			
		||||
        <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
 | 
			
		||||
          {children}
 | 
			
		||||
        </Command>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
    </Dialog>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandInput({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="command-input-wrapper"
 | 
			
		||||
      className="flex h-9 items-center gap-2 border-b px-3"
 | 
			
		||||
    >
 | 
			
		||||
      <SearchIcon className="size-4 shrink-0 opacity-50" />
 | 
			
		||||
      <CommandPrimitive.Input
 | 
			
		||||
        data-slot="command-input"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandList({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive.List
 | 
			
		||||
      data-slot="command-list"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandEmpty({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive.Empty
 | 
			
		||||
      data-slot="command-empty"
 | 
			
		||||
      className="py-6 text-center text-sm"
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandGroup({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive.Group
 | 
			
		||||
      data-slot="command-group"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive.Separator
 | 
			
		||||
      data-slot="command-separator"
 | 
			
		||||
      className={cn("bg-border -mx-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandItem({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <CommandPrimitive.Item
 | 
			
		||||
      data-slot="command-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CommandShortcut({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <span
 | 
			
		||||
      data-slot="command-shortcut"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-muted-foreground ml-auto text-xs tracking-widest",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Command,
 | 
			
		||||
  CommandDialog,
 | 
			
		||||
  CommandInput,
 | 
			
		||||
  CommandList,
 | 
			
		||||
  CommandEmpty,
 | 
			
		||||
  CommandGroup,
 | 
			
		||||
  CommandItem,
 | 
			
		||||
  CommandShortcut,
 | 
			
		||||
  CommandSeparator,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										143
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								src/components/ui/dialog.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,143 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
 | 
			
		||||
import { XIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Dialog({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
 | 
			
		||||
  return <DialogPrimitive.Root data-slot="dialog" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogTrigger({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
 | 
			
		||||
  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogPortal({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
 | 
			
		||||
  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogClose({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
 | 
			
		||||
  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogOverlay({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Overlay
 | 
			
		||||
      data-slot="dialog-overlay"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogContent({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  showCloseButton = true,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
 | 
			
		||||
  showCloseButton?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPortal data-slot="dialog-portal">
 | 
			
		||||
      <DialogOverlay />
 | 
			
		||||
      <DialogPrimitive.Content
 | 
			
		||||
        data-slot="dialog-content"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
        {showCloseButton && (
 | 
			
		||||
          <DialogPrimitive.Close
 | 
			
		||||
            data-slot="dialog-close"
 | 
			
		||||
            className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
 | 
			
		||||
          >
 | 
			
		||||
            <XIcon />
 | 
			
		||||
            <span className="sr-only">Close</span>
 | 
			
		||||
          </DialogPrimitive.Close>
 | 
			
		||||
        )}
 | 
			
		||||
      </DialogPrimitive.Content>
 | 
			
		||||
    </DialogPortal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="dialog-header"
 | 
			
		||||
      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="dialog-footer"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogTitle({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Title
 | 
			
		||||
      data-slot="dialog-title"
 | 
			
		||||
      className={cn("text-lg leading-none font-semibold", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DialogDescription({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <DialogPrimitive.Description
 | 
			
		||||
      data-slot="dialog-description"
 | 
			
		||||
      className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogClose,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogDescription,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogOverlay,
 | 
			
		||||
  DialogPortal,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										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;
 | 
			
		||||
							
								
								
									
										7
									
								
								src/schemas/categories/categorySchema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/schemas/categories/categorySchema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export const categorySchema = z.object({
 | 
			
		||||
  name: z.string().min(3, "Name is required. Min 3 characters."),
 | 
			
		||||
  description: z.string().optional(),
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -27,4 +27,6 @@ export const imageSchema = z.object({
 | 
			
		||||
 | 
			
		||||
  artistId: z.string().optional(),
 | 
			
		||||
  albumId: z.string().optional(),
 | 
			
		||||
  tagIds: z.array(z.string()).optional(),
 | 
			
		||||
  categoryIds: z.array(z.string()).optional(),
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										7
									
								
								src/schemas/tags/tagSchema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/schemas/tags/tagSchema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export const tagSchema = z.object({
 | 
			
		||||
  name: z.string().min(3, "Name is required. Min 3 characters."),
 | 
			
		||||
  description: z.string().optional(),
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,17 @@ export function generatePaletteName(tones: Tone[]): string {
 | 
			
		||||
  return `palette-${hash.slice(0, 8)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateColorName(hex: string): string {
 | 
			
		||||
  const hash = crypto.createHash("sha256").update(hex.toLowerCase()).digest("hex");
 | 
			
		||||
  return `color-${hash.slice(0, 8)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function generateExtractColorName(hex: string, hue?: number, sat?: number, area?: number): string {
 | 
			
		||||
  const data = `${hex.toLowerCase()}-${hue ?? 0}-${sat ?? 0}-${area ?? 0}`;
 | 
			
		||||
  const hash = crypto.createHash("sha256").update(data).digest("hex");
 | 
			
		||||
  return `extract-${hash.slice(0, 8)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function rgbToHex(rgb: number[]): string {
 | 
			
		||||
  return `#${rgb
 | 
			
		||||
    .map((val) => Math.round(val).toString(16).padStart(2, "0"))
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user