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",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@prisma/client": "^6.10.1",
|
"@prisma/client": "^6.10.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"extract-colors": "^4.2.0",
|
"extract-colors": "^4.2.0",
|
||||||
"get-pixels": "^3.3.3",
|
"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": {
|
"node_modules/@radix-ui/react-direction": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||||
@ -5361,6 +5399,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@prisma/client": "^6.10.1",
|
"@prisma/client": "^6.10.1",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
@ -22,6 +23,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"extract-colors": "^4.2.0",
|
"extract-colors": "^4.2.0",
|
||||||
"get-pixels": "^3.3.3",
|
"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?
|
// sourceId String?
|
||||||
// source Source? @relation(fields: [sourceId], references: [id])
|
// source Source? @relation(fields: [sourceId], references: [id])
|
||||||
|
|
||||||
colors ImageColor[]
|
metadata ImageMetadata[]
|
||||||
extractColors ExtractColor[]
|
pixels PixelSummary[]
|
||||||
metadata ImageMetadata[]
|
stats ImageStats[]
|
||||||
pixels PixelSummary[]
|
theme ThemeSeed[]
|
||||||
stats ImageStats[]
|
variants ImageVariant[]
|
||||||
theme ThemeSeed[]
|
|
||||||
variants ImageVariant[]
|
|
||||||
//
|
|
||||||
// albumCover Album[] @relation("AlbumCoverImage")
|
// albumCover Album[] @relation("AlbumCoverImage")
|
||||||
// categories Category[] @relation("ImageCategories")
|
|
||||||
// galleryCover Gallery[] @relation("GalleryCoverImage")
|
// galleryCover Gallery[] @relation("GalleryCoverImage")
|
||||||
palettes ColorPalette[] @relation("ImagePalettes")
|
categories Category[] @relation("ImageCategories")
|
||||||
// tags Tag[] @relation("ImageTags")
|
colors ImageColor[] @relation("ImageToImageColor")
|
||||||
|
extractColors ExtractColor[] @relation("ImageToExtractColor")
|
||||||
|
palettes ColorPalette[] @relation("ImagePalettes")
|
||||||
|
tags Tag[] @relation("ImageTags")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ImageMetadata {
|
model ImageMetadata {
|
||||||
@ -183,8 +183,8 @@ model ColorPalette {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
name String?
|
name String
|
||||||
type String?
|
type String
|
||||||
|
|
||||||
items ColorPaletteItem[]
|
items ColorPaletteItem[]
|
||||||
images Image[] @relation("ImagePalettes")
|
images Image[] @relation("ImagePalettes")
|
||||||
@ -207,17 +207,17 @@ model ExtractColor {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
hex String
|
name String @unique
|
||||||
imageId String
|
hex String
|
||||||
blue Int
|
blue Int
|
||||||
green Int
|
green Int
|
||||||
red Int
|
red Int
|
||||||
|
|
||||||
area Float?
|
area Float?
|
||||||
hue Float?
|
hue Float?
|
||||||
saturation Float?
|
saturation Float?
|
||||||
|
|
||||||
image Image @relation(fields: [imageId], references: [id])
|
images Image[] @relation("ImageToExtractColor")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ImageColor {
|
model ImageColor {
|
||||||
@ -225,15 +225,15 @@ model ImageColor {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
imageId String
|
name String @unique
|
||||||
type String
|
type String
|
||||||
|
|
||||||
hex String?
|
hex String?
|
||||||
blue Int?
|
blue Int?
|
||||||
green Int?
|
green Int?
|
||||||
red Int?
|
red Int?
|
||||||
|
|
||||||
image Image @relation(fields: [imageId], references: [id])
|
images Image[] @relation("ImageToImageColor")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ThemeSeed {
|
model ThemeSeed {
|
||||||
@ -259,3 +259,27 @@ model PixelSummary {
|
|||||||
|
|
||||||
image Image @relation(fields: [imageId], references: [id])
|
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";
|
"use server";
|
||||||
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { s3 } from "@/lib/s3";
|
||||||
|
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
export async function deleteImage(id: string) {
|
export async function deleteImage(imageId: string) {
|
||||||
await prisma.gallery.delete({ where: { id } });
|
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 prisma from "@/lib/prisma";
|
||||||
import { s3 } from "@/lib/s3";
|
// import { s3 } from "@/lib/s3";
|
||||||
import { imageUploadSchema } from "@/schemas/images/imageSchema";
|
// import { imageUploadSchema } from "@/schemas/images/imageSchema";
|
||||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
// import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||||
import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
|
// import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
// import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
|
// import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
|
||||||
import { extractColors } from "extract-colors";
|
// import { extractColors } from "extract-colors";
|
||||||
import getPixels from "get-pixels";
|
// import getPixels from "get-pixels";
|
||||||
import { NdArray } from "ndarray";
|
// import { NdArray } from "ndarray";
|
||||||
import { Vibrant } from "node-vibrant/node";
|
// import { Vibrant } from "node-vibrant/node";
|
||||||
import path from "path";
|
// import path from "path";
|
||||||
import sharp from "sharp";
|
// import sharp from "sharp";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
// import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as z from "zod/v4";
|
// import * as z from "zod/v4";
|
||||||
|
|
||||||
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
// export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||||
const imageFile = values.file[0];
|
// const imageFile = values.file[0];
|
||||||
const imageName = values.imageName;
|
// const imageName = values.imageName;
|
||||||
|
|
||||||
if (!(imageFile instanceof File)) {
|
// if (!(imageFile instanceof File)) {
|
||||||
console.log("No image or invalid type");
|
// console.log("No image or invalid type");
|
||||||
return null;
|
// return null;
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!imageName) {
|
// if (!imageName) {
|
||||||
console.log("No name for the image provided");
|
// console.log("No name for the image provided");
|
||||||
return null;
|
// return null;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const fileName = imageFile.name;
|
// const fileName = imageFile.name;
|
||||||
const fileType = imageFile.type;
|
// const fileType = imageFile.type;
|
||||||
const fileSize = imageFile.size;
|
// const fileSize = imageFile.size;
|
||||||
const lastModified = new Date(imageFile.lastModified);
|
// const lastModified = new Date(imageFile.lastModified);
|
||||||
const year = lastModified.getUTCFullYear();
|
// const year = lastModified.getUTCFullYear();
|
||||||
const month = lastModified.getUTCMonth() + 1;
|
// const month = lastModified.getUTCMonth() + 1;
|
||||||
|
|
||||||
const fileKey = uuidv4();
|
// const fileKey = uuidv4();
|
||||||
|
|
||||||
const arrayBuffer = await imageFile.arrayBuffer();
|
// const arrayBuffer = await imageFile.arrayBuffer();
|
||||||
const buffer = Buffer.from(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 originalKey = `original/${fileKey}.webp`;
|
||||||
const watermarkedKey = `watermarked/${fileKey}.webp`;
|
// const watermarkedKey = `watermarked/${fileKey}.webp`;
|
||||||
const resizedKey = `resized/${fileKey}.webp`;
|
// const resizedKey = `resized/${fileKey}.webp`;
|
||||||
const thumbnailKey = `thumbnails/${fileKey}.webp`;
|
// const thumbnailKey = `thumbnails/${fileKey}.webp`;
|
||||||
|
|
||||||
const sharpData = sharp(buffer);
|
// const sharpData = sharp(buffer);
|
||||||
const metadata = await sharpData.metadata();
|
// const metadata = await sharpData.metadata();
|
||||||
const stats = await sharpData.stats();
|
// const stats = await sharpData.stats();
|
||||||
|
|
||||||
const palette = await Vibrant.from(buffer).getPalette();
|
// const palette = await Vibrant.from(buffer).getPalette();
|
||||||
|
|
||||||
const vibrantHexes = Object.fromEntries(
|
// const vibrantHexes = Object.fromEntries(
|
||||||
Object.entries(palette).map(([key, swatch]) => {
|
// Object.entries(palette).map(([key, swatch]) => {
|
||||||
const castSwatch = swatch as VibrantSwatch | null;
|
// const castSwatch = swatch as VibrantSwatch | null;
|
||||||
const rgb = castSwatch?._rgb;
|
// const rgb = castSwatch?._rgb;
|
||||||
const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
|
// const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
|
||||||
return [key, hex];
|
// return [key, hex];
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
|
|
||||||
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
// for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||||
if (!hex) continue;
|
// if (!hex) continue;
|
||||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||||
await prisma.imageColor.create({
|
// await prisma.imageColor.create({
|
||||||
data: {
|
// data: {
|
||||||
type,
|
// type,
|
||||||
hex,
|
// hex,
|
||||||
red: r,
|
// red: r,
|
||||||
green: g,
|
// green: g,
|
||||||
blue: b,
|
// blue: b,
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
const seedHex =
|
// const seedHex =
|
||||||
vibrantHexes.Vibrant ??
|
// vibrantHexes.Vibrant ??
|
||||||
vibrantHexes.Muted ??
|
// vibrantHexes.Muted ??
|
||||||
vibrantHexes.DarkVibrant ??
|
// vibrantHexes.DarkVibrant ??
|
||||||
vibrantHexes.DarkMuted ??
|
// vibrantHexes.DarkMuted ??
|
||||||
vibrantHexes.LightVibrant ??
|
// vibrantHexes.LightVibrant ??
|
||||||
vibrantHexes.LightMuted ??
|
// vibrantHexes.LightMuted ??
|
||||||
"#dfffff";
|
// "#dfffff";
|
||||||
|
|
||||||
const theme = themeFromSourceColor(argbFromHex(seedHex));
|
// const theme = themeFromSourceColor(argbFromHex(seedHex));
|
||||||
const primaryTones = extractPaletteTones(theme.palettes.primary);
|
// const primaryTones = extractPaletteTones(theme.palettes.primary);
|
||||||
const secondaryTones = extractPaletteTones(theme.palettes.secondary);
|
// const secondaryTones = extractPaletteTones(theme.palettes.secondary);
|
||||||
const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
|
// const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
|
||||||
const neutralTones = extractPaletteTones(theme.palettes.neutral);
|
// const neutralTones = extractPaletteTones(theme.palettes.neutral);
|
||||||
const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
|
// const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
|
||||||
const errorTones = extractPaletteTones(theme.palettes.error);
|
// const errorTones = extractPaletteTones(theme.palettes.error);
|
||||||
|
|
||||||
const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
|
// const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
|
||||||
getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
|
// getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
|
||||||
if (err) reject(err);
|
// if (err) reject(err);
|
||||||
else resolve(pixels);
|
// else resolve(pixels);
|
||||||
});
|
// });
|
||||||
});
|
// });
|
||||||
|
|
||||||
const extracted = await extractColors({
|
// const extracted = await extractColors({
|
||||||
data: Array.from(pixels.data),
|
// data: Array.from(pixels.data),
|
||||||
width: pixels.shape[0],
|
// width: pixels.shape[0],
|
||||||
height: pixels.shape[1]
|
// height: pixels.shape[1]
|
||||||
});
|
// });
|
||||||
|
|
||||||
//--- Original file
|
// //--- Original file
|
||||||
await s3.send(
|
// await s3.send(
|
||||||
new PutObjectCommand({
|
// new PutObjectCommand({
|
||||||
Bucket: "felliesartapp",
|
// Bucket: "felliesartapp",
|
||||||
Key: originalKey,
|
// Key: originalKey,
|
||||||
Body: buffer,
|
// Body: buffer,
|
||||||
ContentType: "image/" + metadata.format,
|
// ContentType: "image/" + metadata.format,
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
//--- Watermarked file
|
// //--- Watermarked file
|
||||||
const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
|
// const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
|
||||||
const watermarkWidth = Math.round(metadata.width * 0.25);
|
// const watermarkWidth = Math.round(metadata.width * 0.25);
|
||||||
const watermarkBuffer = await sharp(watermarkPath)
|
// const watermarkBuffer = await sharp(watermarkPath)
|
||||||
.resize({ width: watermarkWidth })
|
// .resize({ width: watermarkWidth })
|
||||||
.png()
|
// .png()
|
||||||
.toBuffer();
|
// .toBuffer();
|
||||||
const watermarkedBuffer = await sharp(buffer)
|
// const watermarkedBuffer = await sharp(buffer)
|
||||||
.composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
|
// .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
|
||||||
.toFormat('webp')
|
// .toFormat('webp')
|
||||||
.toBuffer()
|
// .toBuffer()
|
||||||
const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
|
// const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
|
||||||
await s3.send(
|
// await s3.send(
|
||||||
new PutObjectCommand({
|
// new PutObjectCommand({
|
||||||
Bucket: "felliesartapp",
|
// Bucket: "felliesartapp",
|
||||||
Key: watermarkedKey,
|
// Key: watermarkedKey,
|
||||||
Body: watermarkedBuffer,
|
// Body: watermarkedBuffer,
|
||||||
ContentType: "image/" + watermarkedMetadata.format,
|
// ContentType: "image/" + watermarkedMetadata.format,
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
//--- Resized file
|
// //--- Resized file
|
||||||
const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
|
// const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
|
||||||
const resizedBuffer = await sharp(watermarkedBuffer)
|
// const resizedBuffer = await sharp(watermarkedBuffer)
|
||||||
.resize({ width: resizedWidth, withoutEnlargement: true })
|
// .resize({ width: resizedWidth, withoutEnlargement: true })
|
||||||
.toFormat('webp')
|
// .toFormat('webp')
|
||||||
.toBuffer();
|
// .toBuffer();
|
||||||
const resizedMetadata = await sharp(resizedBuffer).metadata();
|
// const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||||
await s3.send(
|
// await s3.send(
|
||||||
new PutObjectCommand({
|
// new PutObjectCommand({
|
||||||
Bucket: "felliesartapp",
|
// Bucket: "felliesartapp",
|
||||||
Key: resizedKey,
|
// Key: resizedKey,
|
||||||
Body: resizedBuffer,
|
// Body: resizedBuffer,
|
||||||
ContentType: "image/" + resizedMetadata.format,
|
// ContentType: "image/" + resizedMetadata.format,
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
//--- Thumbnail file
|
// //--- Thumbnail file
|
||||||
const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
|
// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
|
||||||
const thumbnailBuffer = await sharp(watermarkedBuffer)
|
// const thumbnailBuffer = await sharp(watermarkedBuffer)
|
||||||
.resize({ width: thumbnailWidth, withoutEnlargement: true })
|
// .resize({ width: thumbnailWidth, withoutEnlargement: true })
|
||||||
.toFormat('webp')
|
// .toFormat('webp')
|
||||||
.toBuffer();
|
// .toBuffer();
|
||||||
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
// const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
||||||
await s3.send(
|
// await s3.send(
|
||||||
new PutObjectCommand({
|
// new PutObjectCommand({
|
||||||
Bucket: "felliesartapp",
|
// Bucket: "felliesartapp",
|
||||||
Key: thumbnailKey,
|
// Key: thumbnailKey,
|
||||||
Body: thumbnailBuffer,
|
// Body: thumbnailBuffer,
|
||||||
ContentType: "image/" + thumbnailMetadata.format,
|
// ContentType: "image/" + thumbnailMetadata.format,
|
||||||
})
|
// })
|
||||||
);
|
// );
|
||||||
|
|
||||||
const image = await prisma.image.create({
|
// const image = await prisma.image.create({
|
||||||
data: {
|
// data: {
|
||||||
imageName,
|
// imageName,
|
||||||
fileKey,
|
// fileKey,
|
||||||
originalFile: fileName,
|
// originalFile: fileName,
|
||||||
uploadDate: new Date(),
|
// uploadDate: new Date(),
|
||||||
|
|
||||||
creationDate: lastModified,
|
// creationDate: lastModified,
|
||||||
creationMonth: month,
|
// creationMonth: month,
|
||||||
creationYear: year,
|
// creationYear: year,
|
||||||
imageData: imageDataUrl,
|
// imageData: imageDataUrl,
|
||||||
fileType: fileType,
|
// fileType: fileType,
|
||||||
fileSize: fileSize,
|
// fileSize: fileSize,
|
||||||
altText: "",
|
// altText: "",
|
||||||
description: "",
|
// description: "",
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
await prisma.imageMetadata.create({
|
// await prisma.imageMetadata.create({
|
||||||
data: {
|
// data: {
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
format: metadata.format || "unknown",
|
// format: metadata.format || "unknown",
|
||||||
width: metadata.width || 0,
|
// width: metadata.width || 0,
|
||||||
height: metadata.height || 0,
|
// height: metadata.height || 0,
|
||||||
space: metadata.space || "unknown",
|
// space: metadata.space || "unknown",
|
||||||
channels: metadata.channels || 0,
|
// channels: metadata.channels || 0,
|
||||||
depth: metadata.depth || "unknown",
|
// depth: metadata.depth || "unknown",
|
||||||
density: metadata.density ?? undefined,
|
// density: metadata.density ?? undefined,
|
||||||
bitsPerSample: metadata.bitsPerSample ?? undefined,
|
// bitsPerSample: metadata.bitsPerSample ?? undefined,
|
||||||
isProgressive: metadata.isProgressive ?? undefined,
|
// isProgressive: metadata.isProgressive ?? undefined,
|
||||||
isPalette: metadata.isPalette ?? undefined,
|
// isPalette: metadata.isPalette ?? undefined,
|
||||||
hasProfile: metadata.hasProfile ?? undefined,
|
// hasProfile: metadata.hasProfile ?? undefined,
|
||||||
hasAlpha: metadata.hasAlpha ?? undefined,
|
// hasAlpha: metadata.hasAlpha ?? undefined,
|
||||||
autoOrientW: metadata.autoOrient?.width ?? undefined,
|
// autoOrientW: metadata.autoOrient?.width ?? undefined,
|
||||||
autoOrientH: metadata.autoOrient?.height ?? undefined,
|
// autoOrientH: metadata.autoOrient?.height ?? undefined,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
await prisma.imageStats.create({
|
// await prisma.imageStats.create({
|
||||||
data: {
|
// data: {
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
isOpaque: stats.isOpaque,
|
// isOpaque: stats.isOpaque,
|
||||||
entropy: stats.entropy,
|
// entropy: stats.entropy,
|
||||||
sharpness: stats.sharpness,
|
// sharpness: stats.sharpness,
|
||||||
dominantR: stats.dominant.r,
|
// dominantR: stats.dominant.r,
|
||||||
dominantG: stats.dominant.g,
|
// dominantG: stats.dominant.g,
|
||||||
dominantB: stats.dominant.b,
|
// dominantB: stats.dominant.b,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
await prisma.imageVariant.createMany({
|
// await prisma.imageVariant.createMany({
|
||||||
data: [
|
// data: [
|
||||||
{
|
// {
|
||||||
s3Key: originalKey,
|
// s3Key: originalKey,
|
||||||
type: "original",
|
// type: "original",
|
||||||
height: metadata.height,
|
// height: metadata.height,
|
||||||
width: metadata.width,
|
// width: metadata.width,
|
||||||
fileExtension: metadata.format,
|
// fileExtension: metadata.format,
|
||||||
mimeType: "image/" + metadata.format,
|
// mimeType: "image/" + metadata.format,
|
||||||
sizeBytes: metadata.size,
|
// sizeBytes: metadata.size,
|
||||||
imageId: image.id
|
// imageId: image.id
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
s3Key: watermarkedKey,
|
// s3Key: watermarkedKey,
|
||||||
type: "watermarked",
|
// type: "watermarked",
|
||||||
height: watermarkedMetadata.height,
|
// height: watermarkedMetadata.height,
|
||||||
width: watermarkedMetadata.width,
|
// width: watermarkedMetadata.width,
|
||||||
fileExtension: watermarkedMetadata.format,
|
// fileExtension: watermarkedMetadata.format,
|
||||||
mimeType: "image/" + watermarkedMetadata.format,
|
// mimeType: "image/" + watermarkedMetadata.format,
|
||||||
sizeBytes: watermarkedMetadata.size,
|
// sizeBytes: watermarkedMetadata.size,
|
||||||
imageId: image.id
|
// imageId: image.id
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
s3Key: resizedKey,
|
// s3Key: resizedKey,
|
||||||
type: "resized",
|
// type: "resized",
|
||||||
height: resizedMetadata.height,
|
// height: resizedMetadata.height,
|
||||||
width: resizedMetadata.width,
|
// width: resizedMetadata.width,
|
||||||
fileExtension: resizedMetadata.format,
|
// fileExtension: resizedMetadata.format,
|
||||||
mimeType: "image/" + resizedMetadata.format,
|
// mimeType: "image/" + resizedMetadata.format,
|
||||||
sizeBytes: resizedMetadata.size,
|
// sizeBytes: resizedMetadata.size,
|
||||||
imageId: image.id
|
// imageId: image.id
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
s3Key: thumbnailKey,
|
// s3Key: thumbnailKey,
|
||||||
type: "thumbnail",
|
// type: "thumbnail",
|
||||||
height: thumbnailMetadata.height,
|
// height: thumbnailMetadata.height,
|
||||||
width: thumbnailMetadata.width,
|
// width: thumbnailMetadata.width,
|
||||||
fileExtension: thumbnailMetadata.format,
|
// fileExtension: thumbnailMetadata.format,
|
||||||
mimeType: "image/" + thumbnailMetadata.format,
|
// mimeType: "image/" + thumbnailMetadata.format,
|
||||||
sizeBytes: thumbnailMetadata.size,
|
// sizeBytes: thumbnailMetadata.size,
|
||||||
imageId: image.id
|
// imageId: image.id
|
||||||
}
|
// }
|
||||||
],
|
// ],
|
||||||
});
|
// });
|
||||||
|
|
||||||
await upsertPalettes(primaryTones, image.id, "primary");
|
// await upsertPalettes(primaryTones, image.id, "primary");
|
||||||
await upsertPalettes(secondaryTones, image.id, "secondary");
|
// await upsertPalettes(secondaryTones, image.id, "secondary");
|
||||||
await upsertPalettes(tertiaryTones, image.id, "tertiary");
|
// await upsertPalettes(tertiaryTones, image.id, "tertiary");
|
||||||
await upsertPalettes(neutralTones, image.id, "neutral");
|
// await upsertPalettes(neutralTones, image.id, "neutral");
|
||||||
await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
|
// await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
|
||||||
await upsertPalettes(errorTones, image.id, "error");
|
// await upsertPalettes(errorTones, image.id, "error");
|
||||||
|
|
||||||
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
// for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||||
if (!hex) continue;
|
// if (!hex) continue;
|
||||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||||
await prisma.imageColor.create({
|
// await prisma.imageColor.create({
|
||||||
data: {
|
// data: {
|
||||||
type,
|
// type,
|
||||||
hex,
|
// hex,
|
||||||
red: r,
|
// red: r,
|
||||||
green: g,
|
// green: g,
|
||||||
blue: b,
|
// blue: b,
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
for (const c of extracted) {
|
// for (const c of extracted) {
|
||||||
await prisma.extractColor.create({
|
// await prisma.extractColor.create({
|
||||||
data: {
|
// data: {
|
||||||
hex: c.hex,
|
// hex: c.hex,
|
||||||
red: c.red,
|
// red: c.red,
|
||||||
green: c.green,
|
// green: c.green,
|
||||||
blue: c.blue,
|
// blue: c.blue,
|
||||||
hue: c.hue,
|
// hue: c.hue,
|
||||||
saturation: c.saturation,
|
// saturation: c.saturation,
|
||||||
// value: c.value,
|
// // value: c.value,
|
||||||
area: c.area,
|
// area: c.area,
|
||||||
// isLight: c.isLight,
|
// // isLight: c.isLight,
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
await prisma.themeSeed.create({
|
// await prisma.themeSeed.create({
|
||||||
data: {
|
// data: {
|
||||||
seedHex,
|
// seedHex,
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
await prisma.pixelSummary.create({
|
// await prisma.pixelSummary.create({
|
||||||
data: {
|
// data: {
|
||||||
width: pixels.shape[0],
|
// width: pixels.shape[0],
|
||||||
height: pixels.shape[1],
|
// height: pixels.shape[1],
|
||||||
channels: pixels.shape[2],
|
// channels: pixels.shape[2],
|
||||||
imageId: image.id,
|
// imageId: image.id,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
return image
|
// return image
|
||||||
// return await prisma.gallery.create({
|
// // return await prisma.gallery.create({
|
||||||
// data: {
|
// // data: {
|
||||||
// name: values.name,
|
// // name: values.name,
|
||||||
// slug: values.slug,
|
// // slug: values.slug,
|
||||||
// description: values.description,
|
// // description: values.description,
|
||||||
// }
|
// // }
|
||||||
// })
|
// // })
|
||||||
}
|
// }
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
||||||
|
import { generateExtractColorName } from "@/utils/uploadHelper";
|
||||||
import { extractColors } from "extract-colors";
|
import { extractColors } from "extract-colors";
|
||||||
import getPixels from "get-pixels";
|
import getPixels from "get-pixels";
|
||||||
import { NdArray } from "ndarray";
|
import { NdArray } from "ndarray";
|
||||||
@ -34,21 +35,35 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const c of extracted) {
|
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: {
|
data: {
|
||||||
hex: c.hex,
|
extractColors: {
|
||||||
red: c.red,
|
connectOrCreate: {
|
||||||
green: c.green,
|
where: { name },
|
||||||
blue: c.blue,
|
create: {
|
||||||
hue: c.hue,
|
name,
|
||||||
saturation: c.saturation,
|
hex: c.hex,
|
||||||
area: c.area,
|
red: c.red,
|
||||||
imageId: imageId,
|
green: c.green,
|
||||||
|
blue: c.blue,
|
||||||
|
hue: c.hue,
|
||||||
|
saturation: c.saturation,
|
||||||
|
area: c.area,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.extractColor.findMany({
|
return await prisma.extractColor.findMany({
|
||||||
where: { imageId: imageId }
|
where: {
|
||||||
|
images: {
|
||||||
|
some: { id: imageId },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
@ -3,7 +3,7 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
||||||
import { rgbToHex } from "@/utils/uploadHelper";
|
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
|
||||||
import { Vibrant } from "node-vibrant/node";
|
import { Vibrant } from "node-vibrant/node";
|
||||||
|
|
||||||
export async function generateImageColors(imageId: string, fileKey: string) {
|
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)) {
|
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||||
if (!hex) continue;
|
if (!hex) continue;
|
||||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
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: {
|
data: {
|
||||||
type,
|
colors: {
|
||||||
hex,
|
connectOrCreate: {
|
||||||
red: r,
|
where: { name: name },
|
||||||
green: g,
|
create: {
|
||||||
blue: b,
|
name: name,
|
||||||
imageId: imageId,
|
type: type,
|
||||||
|
hex: hex,
|
||||||
|
red: r,
|
||||||
|
green: g,
|
||||||
|
blue: b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.imageColor.findMany({
|
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 EditImageForm from "@/components/images/edit/EditImageForm";
|
||||||
import ExtractColors from "@/components/images/edit/ExtractColors";
|
import ExtractColors from "@/components/images/edit/ExtractColors";
|
||||||
import ImageColors from "@/components/images/edit/ImageColors";
|
import ImageColors from "@/components/images/edit/ImageColors";
|
||||||
@ -26,19 +27,26 @@ export default async function ImagesEditPage({ params }: { params: { id: string
|
|||||||
include: {
|
include: {
|
||||||
items: true
|
items: true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
tags: true,
|
||||||
|
categories: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
|
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
|
||||||
const albums = await prisma.album.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div>
|
<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>
|
<div>
|
||||||
{image && <ImageVariants variants={image.variants} />}
|
{image && <ImageVariants variants={image.variants} />}
|
||||||
</div>
|
</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>
|
<Link href="/artists">Artists</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</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>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||||
<Link href="/images">Images</Link>
|
<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 { Calendar } from "@/components/ui/calendar";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import MultipleSelector from "@/components/ui/multiselect";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { cn } from "@/lib/utils";
|
||||||
import { imageSchema } from "@/schemas/images/imageSchema";
|
import { imageSchema } from "@/schemas/images/imageSchema";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -28,6 +29,8 @@ type ImageWithItems = Image & {
|
|||||||
stats: ImageStats[],
|
stats: ImageStats[],
|
||||||
theme: ThemeSeed[],
|
theme: ThemeSeed[],
|
||||||
variants: ImageVariant[],
|
variants: ImageVariant[],
|
||||||
|
tags: Tag[],
|
||||||
|
categories: Category[],
|
||||||
palettes: (
|
palettes: (
|
||||||
ColorPalette & {
|
ColorPalette & {
|
||||||
items: ColorPaletteItem[]
|
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 router = useRouter();
|
||||||
const form = useForm<z.infer<typeof imageSchema>>({
|
const form = useForm<z.infer<typeof imageSchema>>({
|
||||||
resolver: zodResolver(imageSchema),
|
resolver: zodResolver(imageSchema),
|
||||||
@ -56,6 +66,8 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
|
|||||||
|
|
||||||
artistId: image.artist?.id || undefined,
|
artistId: image.artist?.id || undefined,
|
||||||
albumId: image.album?.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">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</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(),
|
artistId: z.string().optional(),
|
||||||
albumId: 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)}`;
|
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 {
|
export function rgbToHex(rgb: number[]): string {
|
||||||
return `#${rgb
|
return `#${rgb
|
||||||
.map((val) => Math.round(val).toString(16).padStart(2, "0"))
|
.map((val) => Math.round(val).toString(16).padStart(2, "0"))
|
||||||
|
Reference in New Issue
Block a user