Add tags and categories CRUD
This commit is contained in:
54
package-lock.json
generated
54
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
@ -21,6 +22,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"extract-colors": "^4.2.0",
|
||||
"get-pixels": "^3.3.3",
|
||||
@ -2294,6 +2296,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
|
||||
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.10",
|
||||
"@radix-ui/react-focus-guards": "1.1.2",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.4",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
|
||||
@ -5361,6 +5399,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
@ -14,6 +14,7 @@
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
@ -22,6 +23,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"extract-colors": "^4.2.0",
|
||||
"get-pixels": "^3.3.3",
|
||||
|
68
prisma/migrations/20250627223557_image_colors/migration.sql
Normal file
68
prisma/migrations/20250627223557_image_colors/migration.sql
Normal file
@ -0,0 +1,68 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `imageId` on the `ExtractColor` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[name]` on the table `ExtractColor` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[name]` on the table `ImageColor` will be added. If there are existing duplicate values, this will fail.
|
||||
- Made the column `name` on table `ColorPalette` required. This step will fail if there are existing NULL values in that column.
|
||||
- Made the column `type` on table `ColorPalette` required. This step will fail if there are existing NULL values in that column.
|
||||
- Added the required column `name` to the `ExtractColor` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `name` to the `ImageColor` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ExtractColor" DROP CONSTRAINT "ExtractColor_imageId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ImageColor" DROP CONSTRAINT "ImageColor_imageId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ColorPalette" ALTER COLUMN "name" SET NOT NULL,
|
||||
ALTER COLUMN "type" SET NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExtractColor" DROP COLUMN "imageId",
|
||||
ADD COLUMN "name" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImageColor" ADD COLUMN "name" TEXT NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ImageToImageColor" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_ImageToImageColor_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ImageToExtractColor" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_ImageToExtractColor_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ImageToImageColor_B_index" ON "_ImageToImageColor"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ImageToExtractColor_B_index" ON "_ImageToExtractColor"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ExtractColor_name_key" ON "ExtractColor"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ImageColor_name_key" ON "ImageColor"("name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToImageColor" ADD CONSTRAINT "_ImageToImageColor_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToImageColor" ADD CONSTRAINT "_ImageToImageColor_B_fkey" FOREIGN KEY ("B") REFERENCES "ImageColor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToExtractColor" ADD CONSTRAINT "_ImageToExtractColor_A_fkey" FOREIGN KEY ("A") REFERENCES "ExtractColor"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToExtractColor" ADD CONSTRAINT "_ImageToExtractColor_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `imageId` on the `ImageColor` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ImageColor" DROP COLUMN "imageId";
|
@ -0,0 +1,61 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ImageTags" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_ImageTags_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ImageCategories" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_ImageCategories_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ImageTags_B_index" ON "_ImageTags"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ImageCategories_B_index" ON "_ImageCategories"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageTags" ADD CONSTRAINT "_ImageTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageTags" ADD CONSTRAINT "_ImageTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageCategories" ADD CONSTRAINT "_ImageCategories_A_fkey" FOREIGN KEY ("A") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageCategories" ADD CONSTRAINT "_ImageCategories_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -103,19 +103,19 @@ model Image {
|
||||
// sourceId String?
|
||||
// source Source? @relation(fields: [sourceId], references: [id])
|
||||
|
||||
colors ImageColor[]
|
||||
extractColors ExtractColor[]
|
||||
metadata ImageMetadata[]
|
||||
pixels PixelSummary[]
|
||||
stats ImageStats[]
|
||||
theme ThemeSeed[]
|
||||
variants ImageVariant[]
|
||||
//
|
||||
|
||||
// albumCover Album[] @relation("AlbumCoverImage")
|
||||
// categories Category[] @relation("ImageCategories")
|
||||
// galleryCover Gallery[] @relation("GalleryCoverImage")
|
||||
categories Category[] @relation("ImageCategories")
|
||||
colors ImageColor[] @relation("ImageToImageColor")
|
||||
extractColors ExtractColor[] @relation("ImageToExtractColor")
|
||||
palettes ColorPalette[] @relation("ImagePalettes")
|
||||
// tags Tag[] @relation("ImageTags")
|
||||
tags Tag[] @relation("ImageTags")
|
||||
}
|
||||
|
||||
model ImageMetadata {
|
||||
@ -183,8 +183,8 @@ model ColorPalette {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String?
|
||||
type String?
|
||||
name String
|
||||
type String
|
||||
|
||||
items ColorPaletteItem[]
|
||||
images Image[] @relation("ImagePalettes")
|
||||
@ -207,8 +207,8 @@ model ExtractColor {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
hex String
|
||||
imageId String
|
||||
blue Int
|
||||
green Int
|
||||
red Int
|
||||
@ -217,7 +217,7 @@ model ExtractColor {
|
||||
hue Float?
|
||||
saturation Float?
|
||||
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
images Image[] @relation("ImageToExtractColor")
|
||||
}
|
||||
|
||||
model ImageColor {
|
||||
@ -225,7 +225,7 @@ model ImageColor {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String
|
||||
name String @unique
|
||||
type String
|
||||
|
||||
hex String?
|
||||
@ -233,7 +233,7 @@ model ImageColor {
|
||||
green Int?
|
||||
red Int?
|
||||
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
images Image[] @relation("ImageToImageColor")
|
||||
}
|
||||
|
||||
model ThemeSeed {
|
||||
@ -259,3 +259,27 @@ model PixelSummary {
|
||||
|
||||
image Image @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
|
||||
description String?
|
||||
|
||||
images Image[] @relation("ImageCategories")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
|
||||
description String?
|
||||
|
||||
images Image[] @relation("ImageTags")
|
||||
}
|
||||
|
14
src/actions/categories/createCategory.ts
Normal file
14
src/actions/categories/createCategory.ts
Normal file
@ -0,0 +1,14 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { categorySchema } from "@/schemas/categories/categorySchema";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function createCategory(values: z.infer<typeof categorySchema>) {
|
||||
return await prisma.category.create({
|
||||
data: {
|
||||
name: values.name,
|
||||
description: values.description
|
||||
}
|
||||
})
|
||||
}
|
7
src/actions/categories/deleteCategory.ts
Normal file
7
src/actions/categories/deleteCategory.ts
Normal file
@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
await prisma.category.delete({ where: { id } });
|
||||
}
|
20
src/actions/categories/updateCategory.ts
Normal file
20
src/actions/categories/updateCategory.ts
Normal file
@ -0,0 +1,20 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { categorySchema } from "@/schemas/categories/categorySchema";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function updateCategory(
|
||||
values: z.infer<typeof categorySchema>,
|
||||
id: string
|
||||
) {
|
||||
return await prisma.category.update({
|
||||
where: {
|
||||
id: id
|
||||
},
|
||||
data: {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
}
|
||||
})
|
||||
}
|
@ -1,7 +1,64 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
export async function deleteImage(id: string) {
|
||||
await prisma.gallery.delete({ where: { id } });
|
||||
export async function deleteImage(imageId: string) {
|
||||
const image = await prisma.image.findUnique({
|
||||
where: { id: imageId },
|
||||
include: {
|
||||
variants: true,
|
||||
palettes: { include: { items: true } },
|
||||
colors: true,
|
||||
extractColors: true,
|
||||
theme: true,
|
||||
metadata: true,
|
||||
pixels: true,
|
||||
stats: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) throw new Error("Image not found");
|
||||
|
||||
// Delete S3 objects
|
||||
for (const variant of image.variants) {
|
||||
await s3.send(new DeleteObjectCommand({
|
||||
Bucket: "felliesartapp",
|
||||
Key: variant.s3Key,
|
||||
}));
|
||||
}
|
||||
|
||||
// Delete image variants
|
||||
await prisma.imageVariant.deleteMany({ where: { imageId } });
|
||||
|
||||
// Delete extract colors
|
||||
await prisma.extractColor.deleteMany({ where: { imageId } });
|
||||
|
||||
// Delete image colors
|
||||
await prisma.imageColor.deleteMany({ where: { imageId } });
|
||||
|
||||
// Delete palettes (and items only if no other image uses them)
|
||||
const palettes = await prisma.colorPalette.findMany({
|
||||
where: { images: { some: { id: imageId } } },
|
||||
include: { images: { select: { id: true } }, items: true }
|
||||
});
|
||||
|
||||
for (const palette of palettes) {
|
||||
if (palette.images.length === 1 && palette.images[0].id === imageId) {
|
||||
await prisma.colorPaletteItem.deleteMany({ where: { colorPaletteId: palette.id } });
|
||||
await prisma.colorPalette.delete({ where: { id: palette.id } });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete metadata-related entries
|
||||
await prisma.imageMetadata.deleteMany({ where: { imageId } });
|
||||
await prisma.imageStats.deleteMany({ where: { imageId } });
|
||||
await prisma.pixelSummary.deleteMany({ where: { imageId } });
|
||||
await prisma.themeSeed.deleteMany({ where: { imageId } });
|
||||
|
||||
// Finally delete the image
|
||||
await prisma.image.delete({ where: { id: imageId } });
|
||||
|
||||
return { success: true };
|
||||
}
|
@ -1,330 +1,330 @@
|
||||
"use server"
|
||||
// "use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { imageUploadSchema } from "@/schemas/images/imageSchema";
|
||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||
import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
|
||||
import { extractColors } from "extract-colors";
|
||||
import getPixels from "get-pixels";
|
||||
import { NdArray } from "ndarray";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as z from "zod/v4";
|
||||
// import prisma from "@/lib/prisma";
|
||||
// import { s3 } from "@/lib/s3";
|
||||
// import { imageUploadSchema } from "@/schemas/images/imageSchema";
|
||||
// import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||
// import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
|
||||
// import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
// import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
|
||||
// import { extractColors } from "extract-colors";
|
||||
// import getPixels from "get-pixels";
|
||||
// import { NdArray } from "ndarray";
|
||||
// import { Vibrant } from "node-vibrant/node";
|
||||
// import path from "path";
|
||||
// import sharp from "sharp";
|
||||
// import { v4 as uuidv4 } from 'uuid';
|
||||
// import * as z from "zod/v4";
|
||||
|
||||
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||
const imageFile = values.file[0];
|
||||
const imageName = values.imageName;
|
||||
// export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||
// const imageFile = values.file[0];
|
||||
// const imageName = values.imageName;
|
||||
|
||||
if (!(imageFile instanceof File)) {
|
||||
console.log("No image or invalid type");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!imageName) {
|
||||
console.log("No name for the image provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = imageFile.name;
|
||||
const fileType = imageFile.type;
|
||||
const fileSize = imageFile.size;
|
||||
const lastModified = new Date(imageFile.lastModified);
|
||||
const year = lastModified.getUTCFullYear();
|
||||
const month = lastModified.getUTCMonth() + 1;
|
||||
|
||||
const fileKey = uuidv4();
|
||||
|
||||
const arrayBuffer = await imageFile.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
|
||||
|
||||
const originalKey = `original/${fileKey}.webp`;
|
||||
const watermarkedKey = `watermarked/${fileKey}.webp`;
|
||||
const resizedKey = `resized/${fileKey}.webp`;
|
||||
const thumbnailKey = `thumbnails/${fileKey}.webp`;
|
||||
|
||||
const sharpData = sharp(buffer);
|
||||
const metadata = await sharpData.metadata();
|
||||
const stats = await sharpData.stats();
|
||||
|
||||
const palette = await Vibrant.from(buffer).getPalette();
|
||||
|
||||
const vibrantHexes = Object.fromEntries(
|
||||
Object.entries(palette).map(([key, swatch]) => {
|
||||
const castSwatch = swatch as VibrantSwatch | null;
|
||||
const rgb = castSwatch?._rgb;
|
||||
const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
|
||||
return [key, hex];
|
||||
})
|
||||
);
|
||||
|
||||
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||
if (!hex) continue;
|
||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
await prisma.imageColor.create({
|
||||
data: {
|
||||
type,
|
||||
hex,
|
||||
red: r,
|
||||
green: g,
|
||||
blue: b,
|
||||
imageId: image.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const seedHex =
|
||||
vibrantHexes.Vibrant ??
|
||||
vibrantHexes.Muted ??
|
||||
vibrantHexes.DarkVibrant ??
|
||||
vibrantHexes.DarkMuted ??
|
||||
vibrantHexes.LightVibrant ??
|
||||
vibrantHexes.LightMuted ??
|
||||
"#dfffff";
|
||||
|
||||
const theme = themeFromSourceColor(argbFromHex(seedHex));
|
||||
const primaryTones = extractPaletteTones(theme.palettes.primary);
|
||||
const secondaryTones = extractPaletteTones(theme.palettes.secondary);
|
||||
const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
|
||||
const neutralTones = extractPaletteTones(theme.palettes.neutral);
|
||||
const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
|
||||
const errorTones = extractPaletteTones(theme.palettes.error);
|
||||
|
||||
const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
|
||||
getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
|
||||
if (err) reject(err);
|
||||
else resolve(pixels);
|
||||
});
|
||||
});
|
||||
|
||||
const extracted = await extractColors({
|
||||
data: Array.from(pixels.data),
|
||||
width: pixels.shape[0],
|
||||
height: pixels.shape[1]
|
||||
});
|
||||
|
||||
//--- Original file
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "felliesartapp",
|
||||
Key: originalKey,
|
||||
Body: buffer,
|
||||
ContentType: "image/" + metadata.format,
|
||||
})
|
||||
);
|
||||
//--- Watermarked file
|
||||
const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
|
||||
const watermarkWidth = Math.round(metadata.width * 0.25);
|
||||
const watermarkBuffer = await sharp(watermarkPath)
|
||||
.resize({ width: watermarkWidth })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const watermarkedBuffer = await sharp(buffer)
|
||||
.composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
|
||||
.toFormat('webp')
|
||||
.toBuffer()
|
||||
const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "felliesartapp",
|
||||
Key: watermarkedKey,
|
||||
Body: watermarkedBuffer,
|
||||
ContentType: "image/" + watermarkedMetadata.format,
|
||||
})
|
||||
);
|
||||
//--- Resized file
|
||||
const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
|
||||
const resizedBuffer = await sharp(watermarkedBuffer)
|
||||
.resize({ width: resizedWidth, withoutEnlargement: true })
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "felliesartapp",
|
||||
Key: resizedKey,
|
||||
Body: resizedBuffer,
|
||||
ContentType: "image/" + resizedMetadata.format,
|
||||
})
|
||||
);
|
||||
//--- Thumbnail file
|
||||
const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
|
||||
const thumbnailBuffer = await sharp(watermarkedBuffer)
|
||||
.resize({ width: thumbnailWidth, withoutEnlargement: true })
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "felliesartapp",
|
||||
Key: thumbnailKey,
|
||||
Body: thumbnailBuffer,
|
||||
ContentType: "image/" + thumbnailMetadata.format,
|
||||
})
|
||||
);
|
||||
|
||||
const image = await prisma.image.create({
|
||||
data: {
|
||||
imageName,
|
||||
fileKey,
|
||||
originalFile: fileName,
|
||||
uploadDate: new Date(),
|
||||
|
||||
creationDate: lastModified,
|
||||
creationMonth: month,
|
||||
creationYear: year,
|
||||
imageData: imageDataUrl,
|
||||
fileType: fileType,
|
||||
fileSize: fileSize,
|
||||
altText: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.imageMetadata.create({
|
||||
data: {
|
||||
imageId: image.id,
|
||||
format: metadata.format || "unknown",
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
space: metadata.space || "unknown",
|
||||
channels: metadata.channels || 0,
|
||||
depth: metadata.depth || "unknown",
|
||||
density: metadata.density ?? undefined,
|
||||
bitsPerSample: metadata.bitsPerSample ?? undefined,
|
||||
isProgressive: metadata.isProgressive ?? undefined,
|
||||
isPalette: metadata.isPalette ?? undefined,
|
||||
hasProfile: metadata.hasProfile ?? undefined,
|
||||
hasAlpha: metadata.hasAlpha ?? undefined,
|
||||
autoOrientW: metadata.autoOrient?.width ?? undefined,
|
||||
autoOrientH: metadata.autoOrient?.height ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.imageStats.create({
|
||||
data: {
|
||||
imageId: image.id,
|
||||
isOpaque: stats.isOpaque,
|
||||
entropy: stats.entropy,
|
||||
sharpness: stats.sharpness,
|
||||
dominantR: stats.dominant.r,
|
||||
dominantG: stats.dominant.g,
|
||||
dominantB: stats.dominant.b,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.imageVariant.createMany({
|
||||
data: [
|
||||
{
|
||||
s3Key: originalKey,
|
||||
type: "original",
|
||||
height: metadata.height,
|
||||
width: metadata.width,
|
||||
fileExtension: metadata.format,
|
||||
mimeType: "image/" + metadata.format,
|
||||
sizeBytes: metadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: watermarkedKey,
|
||||
type: "watermarked",
|
||||
height: watermarkedMetadata.height,
|
||||
width: watermarkedMetadata.width,
|
||||
fileExtension: watermarkedMetadata.format,
|
||||
mimeType: "image/" + watermarkedMetadata.format,
|
||||
sizeBytes: watermarkedMetadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: resizedKey,
|
||||
type: "resized",
|
||||
height: resizedMetadata.height,
|
||||
width: resizedMetadata.width,
|
||||
fileExtension: resizedMetadata.format,
|
||||
mimeType: "image/" + resizedMetadata.format,
|
||||
sizeBytes: resizedMetadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: thumbnailKey,
|
||||
type: "thumbnail",
|
||||
height: thumbnailMetadata.height,
|
||||
width: thumbnailMetadata.width,
|
||||
fileExtension: thumbnailMetadata.format,
|
||||
mimeType: "image/" + thumbnailMetadata.format,
|
||||
sizeBytes: thumbnailMetadata.size,
|
||||
imageId: image.id
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
await upsertPalettes(primaryTones, image.id, "primary");
|
||||
await upsertPalettes(secondaryTones, image.id, "secondary");
|
||||
await upsertPalettes(tertiaryTones, image.id, "tertiary");
|
||||
await upsertPalettes(neutralTones, image.id, "neutral");
|
||||
await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
|
||||
await upsertPalettes(errorTones, image.id, "error");
|
||||
|
||||
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||
if (!hex) continue;
|
||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
await prisma.imageColor.create({
|
||||
data: {
|
||||
type,
|
||||
hex,
|
||||
red: r,
|
||||
green: g,
|
||||
blue: b,
|
||||
imageId: image.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of extracted) {
|
||||
await prisma.extractColor.create({
|
||||
data: {
|
||||
hex: c.hex,
|
||||
red: c.red,
|
||||
green: c.green,
|
||||
blue: c.blue,
|
||||
hue: c.hue,
|
||||
saturation: c.saturation,
|
||||
// value: c.value,
|
||||
area: c.area,
|
||||
// isLight: c.isLight,
|
||||
imageId: image.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.themeSeed.create({
|
||||
data: {
|
||||
seedHex,
|
||||
imageId: image.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.pixelSummary.create({
|
||||
data: {
|
||||
width: pixels.shape[0],
|
||||
height: pixels.shape[1],
|
||||
channels: pixels.shape[2],
|
||||
imageId: image.id,
|
||||
},
|
||||
});
|
||||
|
||||
return image
|
||||
// return await prisma.gallery.create({
|
||||
// data: {
|
||||
// name: values.name,
|
||||
// slug: values.slug,
|
||||
// description: values.description,
|
||||
// if (!(imageFile instanceof File)) {
|
||||
// console.log("No image or invalid type");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// if (!imageName) {
|
||||
// console.log("No name for the image provided");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// const fileName = imageFile.name;
|
||||
// const fileType = imageFile.type;
|
||||
// const fileSize = imageFile.size;
|
||||
// const lastModified = new Date(imageFile.lastModified);
|
||||
// const year = lastModified.getUTCFullYear();
|
||||
// const month = lastModified.getUTCMonth() + 1;
|
||||
|
||||
// const fileKey = uuidv4();
|
||||
|
||||
// const arrayBuffer = await imageFile.arrayBuffer();
|
||||
// const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
|
||||
|
||||
// const originalKey = `original/${fileKey}.webp`;
|
||||
// const watermarkedKey = `watermarked/${fileKey}.webp`;
|
||||
// const resizedKey = `resized/${fileKey}.webp`;
|
||||
// const thumbnailKey = `thumbnails/${fileKey}.webp`;
|
||||
|
||||
// const sharpData = sharp(buffer);
|
||||
// const metadata = await sharpData.metadata();
|
||||
// const stats = await sharpData.stats();
|
||||
|
||||
// const palette = await Vibrant.from(buffer).getPalette();
|
||||
|
||||
// const vibrantHexes = Object.fromEntries(
|
||||
// Object.entries(palette).map(([key, swatch]) => {
|
||||
// const castSwatch = swatch as VibrantSwatch | null;
|
||||
// const rgb = castSwatch?._rgb;
|
||||
// const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
|
||||
// return [key, hex];
|
||||
// })
|
||||
}
|
||||
// );
|
||||
|
||||
// for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||
// if (!hex) continue;
|
||||
// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
// await prisma.imageColor.create({
|
||||
// data: {
|
||||
// type,
|
||||
// hex,
|
||||
// red: r,
|
||||
// green: g,
|
||||
// blue: b,
|
||||
// imageId: image.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// const seedHex =
|
||||
// vibrantHexes.Vibrant ??
|
||||
// vibrantHexes.Muted ??
|
||||
// vibrantHexes.DarkVibrant ??
|
||||
// vibrantHexes.DarkMuted ??
|
||||
// vibrantHexes.LightVibrant ??
|
||||
// vibrantHexes.LightMuted ??
|
||||
// "#dfffff";
|
||||
|
||||
// const theme = themeFromSourceColor(argbFromHex(seedHex));
|
||||
// const primaryTones = extractPaletteTones(theme.palettes.primary);
|
||||
// const secondaryTones = extractPaletteTones(theme.palettes.secondary);
|
||||
// const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
|
||||
// const neutralTones = extractPaletteTones(theme.palettes.neutral);
|
||||
// const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
|
||||
// const errorTones = extractPaletteTones(theme.palettes.error);
|
||||
|
||||
// const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
|
||||
// getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
|
||||
// if (err) reject(err);
|
||||
// else resolve(pixels);
|
||||
// });
|
||||
// });
|
||||
|
||||
// const extracted = await extractColors({
|
||||
// data: Array.from(pixels.data),
|
||||
// width: pixels.shape[0],
|
||||
// height: pixels.shape[1]
|
||||
// });
|
||||
|
||||
// //--- Original file
|
||||
// await s3.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "felliesartapp",
|
||||
// Key: originalKey,
|
||||
// Body: buffer,
|
||||
// ContentType: "image/" + metadata.format,
|
||||
// })
|
||||
// );
|
||||
// //--- Watermarked file
|
||||
// const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
|
||||
// const watermarkWidth = Math.round(metadata.width * 0.25);
|
||||
// const watermarkBuffer = await sharp(watermarkPath)
|
||||
// .resize({ width: watermarkWidth })
|
||||
// .png()
|
||||
// .toBuffer();
|
||||
// const watermarkedBuffer = await sharp(buffer)
|
||||
// .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
|
||||
// .toFormat('webp')
|
||||
// .toBuffer()
|
||||
// const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
|
||||
// await s3.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "felliesartapp",
|
||||
// Key: watermarkedKey,
|
||||
// Body: watermarkedBuffer,
|
||||
// ContentType: "image/" + watermarkedMetadata.format,
|
||||
// })
|
||||
// );
|
||||
// //--- Resized file
|
||||
// const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
|
||||
// const resizedBuffer = await sharp(watermarkedBuffer)
|
||||
// .resize({ width: resizedWidth, withoutEnlargement: true })
|
||||
// .toFormat('webp')
|
||||
// .toBuffer();
|
||||
// const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||
// await s3.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "felliesartapp",
|
||||
// Key: resizedKey,
|
||||
// Body: resizedBuffer,
|
||||
// ContentType: "image/" + resizedMetadata.format,
|
||||
// })
|
||||
// );
|
||||
// //--- Thumbnail file
|
||||
// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
|
||||
// const thumbnailBuffer = await sharp(watermarkedBuffer)
|
||||
// .resize({ width: thumbnailWidth, withoutEnlargement: true })
|
||||
// .toFormat('webp')
|
||||
// .toBuffer();
|
||||
// const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
||||
// await s3.send(
|
||||
// new PutObjectCommand({
|
||||
// Bucket: "felliesartapp",
|
||||
// Key: thumbnailKey,
|
||||
// Body: thumbnailBuffer,
|
||||
// ContentType: "image/" + thumbnailMetadata.format,
|
||||
// })
|
||||
// );
|
||||
|
||||
// const image = await prisma.image.create({
|
||||
// data: {
|
||||
// imageName,
|
||||
// fileKey,
|
||||
// originalFile: fileName,
|
||||
// uploadDate: new Date(),
|
||||
|
||||
// creationDate: lastModified,
|
||||
// creationMonth: month,
|
||||
// creationYear: year,
|
||||
// imageData: imageDataUrl,
|
||||
// fileType: fileType,
|
||||
// fileSize: fileSize,
|
||||
// altText: "",
|
||||
// description: "",
|
||||
// },
|
||||
// });
|
||||
|
||||
// await prisma.imageMetadata.create({
|
||||
// data: {
|
||||
// imageId: image.id,
|
||||
// format: metadata.format || "unknown",
|
||||
// width: metadata.width || 0,
|
||||
// height: metadata.height || 0,
|
||||
// space: metadata.space || "unknown",
|
||||
// channels: metadata.channels || 0,
|
||||
// depth: metadata.depth || "unknown",
|
||||
// density: metadata.density ?? undefined,
|
||||
// bitsPerSample: metadata.bitsPerSample ?? undefined,
|
||||
// isProgressive: metadata.isProgressive ?? undefined,
|
||||
// isPalette: metadata.isPalette ?? undefined,
|
||||
// hasProfile: metadata.hasProfile ?? undefined,
|
||||
// hasAlpha: metadata.hasAlpha ?? undefined,
|
||||
// autoOrientW: metadata.autoOrient?.width ?? undefined,
|
||||
// autoOrientH: metadata.autoOrient?.height ?? undefined,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await prisma.imageStats.create({
|
||||
// data: {
|
||||
// imageId: image.id,
|
||||
// isOpaque: stats.isOpaque,
|
||||
// entropy: stats.entropy,
|
||||
// sharpness: stats.sharpness,
|
||||
// dominantR: stats.dominant.r,
|
||||
// dominantG: stats.dominant.g,
|
||||
// dominantB: stats.dominant.b,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await prisma.imageVariant.createMany({
|
||||
// data: [
|
||||
// {
|
||||
// s3Key: originalKey,
|
||||
// type: "original",
|
||||
// height: metadata.height,
|
||||
// width: metadata.width,
|
||||
// fileExtension: metadata.format,
|
||||
// mimeType: "image/" + metadata.format,
|
||||
// sizeBytes: metadata.size,
|
||||
// imageId: image.id
|
||||
// },
|
||||
// {
|
||||
// s3Key: watermarkedKey,
|
||||
// type: "watermarked",
|
||||
// height: watermarkedMetadata.height,
|
||||
// width: watermarkedMetadata.width,
|
||||
// fileExtension: watermarkedMetadata.format,
|
||||
// mimeType: "image/" + watermarkedMetadata.format,
|
||||
// sizeBytes: watermarkedMetadata.size,
|
||||
// imageId: image.id
|
||||
// },
|
||||
// {
|
||||
// s3Key: resizedKey,
|
||||
// type: "resized",
|
||||
// height: resizedMetadata.height,
|
||||
// width: resizedMetadata.width,
|
||||
// fileExtension: resizedMetadata.format,
|
||||
// mimeType: "image/" + resizedMetadata.format,
|
||||
// sizeBytes: resizedMetadata.size,
|
||||
// imageId: image.id
|
||||
// },
|
||||
// {
|
||||
// s3Key: thumbnailKey,
|
||||
// type: "thumbnail",
|
||||
// height: thumbnailMetadata.height,
|
||||
// width: thumbnailMetadata.width,
|
||||
// fileExtension: thumbnailMetadata.format,
|
||||
// mimeType: "image/" + thumbnailMetadata.format,
|
||||
// sizeBytes: thumbnailMetadata.size,
|
||||
// imageId: image.id
|
||||
// }
|
||||
// ],
|
||||
// });
|
||||
|
||||
// await upsertPalettes(primaryTones, image.id, "primary");
|
||||
// await upsertPalettes(secondaryTones, image.id, "secondary");
|
||||
// await upsertPalettes(tertiaryTones, image.id, "tertiary");
|
||||
// await upsertPalettes(neutralTones, image.id, "neutral");
|
||||
// await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
|
||||
// await upsertPalettes(errorTones, image.id, "error");
|
||||
|
||||
// for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||
// if (!hex) continue;
|
||||
// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
// await prisma.imageColor.create({
|
||||
// data: {
|
||||
// type,
|
||||
// hex,
|
||||
// red: r,
|
||||
// green: g,
|
||||
// blue: b,
|
||||
// imageId: image.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// for (const c of extracted) {
|
||||
// await prisma.extractColor.create({
|
||||
// data: {
|
||||
// hex: c.hex,
|
||||
// red: c.red,
|
||||
// green: c.green,
|
||||
// blue: c.blue,
|
||||
// hue: c.hue,
|
||||
// saturation: c.saturation,
|
||||
// // value: c.value,
|
||||
// area: c.area,
|
||||
// // isLight: c.isLight,
|
||||
// imageId: image.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// await prisma.themeSeed.create({
|
||||
// data: {
|
||||
// seedHex,
|
||||
// imageId: image.id,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await prisma.pixelSummary.create({
|
||||
// data: {
|
||||
// width: pixels.shape[0],
|
||||
// height: pixels.shape[1],
|
||||
// channels: pixels.shape[2],
|
||||
// imageId: image.id,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return image
|
||||
// // return await prisma.gallery.create({
|
||||
// // data: {
|
||||
// // name: values.name,
|
||||
// // slug: values.slug,
|
||||
// // description: values.description,
|
||||
// // }
|
||||
// // })
|
||||
// }
|
@ -2,6 +2,7 @@
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
||||
import { generateExtractColorName } from "@/utils/uploadHelper";
|
||||
import { extractColors } from "extract-colors";
|
||||
import getPixels from "get-pixels";
|
||||
import { NdArray } from "ndarray";
|
||||
@ -34,8 +35,16 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
|
||||
});
|
||||
|
||||
for (const c of extracted) {
|
||||
await prisma.extractColor.create({
|
||||
const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: imageId },
|
||||
data: {
|
||||
extractColors: {
|
||||
connectOrCreate: {
|
||||
where: { name },
|
||||
create: {
|
||||
name,
|
||||
hex: c.hex,
|
||||
red: c.red,
|
||||
green: c.green,
|
||||
@ -43,12 +52,18 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
|
||||
hue: c.hue,
|
||||
saturation: c.saturation,
|
||||
area: c.area,
|
||||
imageId: imageId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.extractColor.findMany({
|
||||
where: { imageId: imageId }
|
||||
where: {
|
||||
images: {
|
||||
some: { id: imageId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
||||
import { rgbToHex } from "@/utils/uploadHelper";
|
||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
|
||||
export async function generateImageColors(imageId: string, fileKey: string) {
|
||||
@ -22,19 +22,33 @@ export async function generateImageColors(imageId: string, fileKey: string) {
|
||||
for (const [type, hex] of Object.entries(vibrantHexes)) {
|
||||
if (!hex) continue;
|
||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
await prisma.imageColor.create({
|
||||
const name = generateColorName(hex);
|
||||
|
||||
await prisma.image.update({
|
||||
where: { id: imageId },
|
||||
data: {
|
||||
type,
|
||||
hex,
|
||||
colors: {
|
||||
connectOrCreate: {
|
||||
where: { name: name },
|
||||
create: {
|
||||
name: name,
|
||||
type: type,
|
||||
hex: hex,
|
||||
red: r,
|
||||
green: g,
|
||||
blue: b,
|
||||
imageId: imageId,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.imageColor.findMany({
|
||||
where: { imageId: imageId }
|
||||
where: {
|
||||
images: {
|
||||
some: { id: imageId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
14
src/actions/tags/createTag.ts
Normal file
14
src/actions/tags/createTag.ts
Normal file
@ -0,0 +1,14 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { tagSchema } from "@/schemas/tags/tagSchema";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function createTag(values: z.infer<typeof tagSchema>) {
|
||||
return await prisma.tag.create({
|
||||
data: {
|
||||
name: values.name,
|
||||
description: values.description
|
||||
}
|
||||
})
|
||||
}
|
7
src/actions/tags/deleteTag.ts
Normal file
7
src/actions/tags/deleteTag.ts
Normal file
@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export async function deleteTag(id: string) {
|
||||
await prisma.tag.delete({ where: { id } });
|
||||
}
|
20
src/actions/tags/updateTag.ts
Normal file
20
src/actions/tags/updateTag.ts
Normal file
@ -0,0 +1,20 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { tagSchema } from "@/schemas/tags/tagSchema";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function updateTag(
|
||||
values: z.infer<typeof tagSchema>,
|
||||
id: string
|
||||
) {
|
||||
return await prisma.tag.update({
|
||||
where: {
|
||||
id: id
|
||||
},
|
||||
data: {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
}
|
||||
})
|
||||
}
|
19
src/app/categories/edit/[id]/page.tsx
Normal file
19
src/app/categories/edit/[id]/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import EditCategoryForm from "@/components/categories/edit/EditCategoryForm";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function CategoriesEditPage({ params }: { params: { id: string } }) {
|
||||
const { id } = await params;
|
||||
|
||||
const cat = await prisma.category.findUnique({
|
||||
where: {
|
||||
id,
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit category</h1>
|
||||
{cat ? <EditCategoryForm category={cat} /> : 'Category not found...'}
|
||||
</div>
|
||||
);
|
||||
}
|
10
src/app/categories/new/page.tsx
Normal file
10
src/app/categories/new/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import CreateCategoryForm from "@/components/categories/new/CreateCategoryForm";
|
||||
|
||||
export default async function CategoriesNewPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">New category</h1>
|
||||
<CreateCategoryForm />
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/app/categories/page.tsx
Normal file
24
src/app/categories/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import ListCategories from "@/components/categories/list/ListCategories";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await prisma.category.findMany(
|
||||
{
|
||||
orderBy: { createdAt: "asc" }
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 justify-between">
|
||||
<h1 className="text-2xl font-bold mb-4">Categories</h1>
|
||||
<Link href="/categories/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Category
|
||||
</Link>
|
||||
</div>
|
||||
{categories.length > 0 ? <ListCategories categories={categories} /> : <p className="text-muted-foreground italic">No categories found.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import DeleteImageButton from "@/components/images/edit/DeleteImageButton";
|
||||
import EditImageForm from "@/components/images/edit/EditImageForm";
|
||||
import ExtractColors from "@/components/images/edit/ExtractColors";
|
||||
import ImageColors from "@/components/images/edit/ImageColors";
|
||||
@ -26,19 +27,26 @@ export default async function ImagesEditPage({ params }: { params: { id: string
|
||||
include: {
|
||||
items: true
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: true,
|
||||
categories: true
|
||||
}
|
||||
});
|
||||
|
||||
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
|
||||
const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" } });
|
||||
const tags = await prisma.tag.findMany({ orderBy: { createdAt: "asc" } });
|
||||
const categories = await prisma.category.findMany({ orderBy: { createdAt: "asc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
{image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'}
|
||||
{image ? <EditImageForm image={image} artists={artists} albums={albums} tags={tags} categories={categories} /> : 'Image not found...'}
|
||||
<div className="mt-6">
|
||||
{image && <DeleteImageButton imageId={image.id} />}
|
||||
</div>
|
||||
<div>
|
||||
{image && <ImageVariants variants={image.variants} />}
|
||||
</div>
|
||||
|
19
src/app/tags/edit/[id]/page.tsx
Normal file
19
src/app/tags/edit/[id]/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import EditTagForm from "@/components/tags/edit/EditTagForm";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function TagsEditPage({ params }: { params: { id: string } }) {
|
||||
const { id } = await params;
|
||||
|
||||
const tag = await prisma.tag.findUnique({
|
||||
where: {
|
||||
id,
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit tag</h1>
|
||||
{tag ? <EditTagForm tag={tag} /> : 'Tag not found...'}
|
||||
</div>
|
||||
);
|
||||
}
|
10
src/app/tags/new/page.tsx
Normal file
10
src/app/tags/new/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import CreateTagForm from "@/components/tags/new/CreateTagForm";
|
||||
|
||||
export default async function TagsNewPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">New tag</h1>
|
||||
<CreateTagForm />
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/app/tags/page.tsx
Normal file
24
src/app/tags/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import ListTags from "@/components/tags/list/ListTags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function TagsPage() {
|
||||
const tags = await prisma.tag.findMany(
|
||||
{
|
||||
orderBy: { createdAt: "asc" }
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 justify-between">
|
||||
<h1 className="text-2xl font-bold mb-4">Tags</h1>
|
||||
<Link href="/tags/new" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Add new Tag
|
||||
</Link>
|
||||
</div>
|
||||
{tags.length > 0 ? <ListTags tags={tags} /> : <p className="text-muted-foreground italic">No tags found.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
91
src/components/categories/edit/EditCategoryForm.tsx
Normal file
91
src/components/categories/edit/EditCategoryForm.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { deleteCategory } from "@/actions/categories/deleteCategory";
|
||||
import { updateCategory } from "@/actions/categories/updateCategory";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Category } from "@/generated/prisma";
|
||||
import { categorySchema } from "@/schemas/categories/categorySchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export default function EditCategoryForm({ category }: { category: Category }) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof categorySchema>>({
|
||||
resolver: zodResolver(categorySchema),
|
||||
defaultValues: {
|
||||
name: category.name,
|
||||
description: category.description || "",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof categorySchema>) {
|
||||
const updatedCategory = await updateCategory(values, category.id)
|
||||
if (updatedCategory) {
|
||||
toast.success("Category updated")
|
||||
router.push(`/categories`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Category name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Category description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Description of the Category.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await deleteCategory(category.id);
|
||||
toast.success("Category deleted");
|
||||
router.push("/categories");
|
||||
}}
|
||||
>
|
||||
Delete Category
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/components/categories/list/ListCategories.tsx
Normal file
24
src/components/categories/list/ListCategories.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Category } from "@/generated/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ListCategories({ categories }: { categories: Category[] }) {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{categories.map((cat) => (
|
||||
<Link href={`/categories/edit/${cat.id}`} key={cat.id}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base truncate">{cat.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cat.description && <p className="text-sm text-muted-foreground">{cat.description}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
74
src/components/categories/new/CreateCategoryForm.tsx
Normal file
74
src/components/categories/new/CreateCategoryForm.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { createCategory } from "@/actions/categories/createCategory";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { categorySchema } from "@/schemas/categories/categorySchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export default function CreateCategoryForm() {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof categorySchema>>({
|
||||
resolver: zodResolver(categorySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof categorySchema>) {
|
||||
const cat = await createCategory(values)
|
||||
if (cat) {
|
||||
toast.success("Category created")
|
||||
router.push(`/categories`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Category name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Category description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Description of the category.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
@ -27,6 +27,16 @@ export default function TopNav() {
|
||||
<Link href="/artists">Artists</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/categories">Categories</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/tags">Tags</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/images">Images</Link>
|
||||
|
26
src/components/images/edit/DeleteImageButton.tsx
Normal file
26
src/components/images/edit/DeleteImageButton.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { deleteImage } from "@/actions/images/deleteImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function DeleteImageButton({ imageId }: { imageId: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm("Are you sure you want to delete this image? This action is irreversible.")) {
|
||||
const result = await deleteImage(imageId);
|
||||
if (result?.success) {
|
||||
router.push("/images"); // redirect to image list or gallery
|
||||
} else {
|
||||
alert("Failed to delete image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete Image
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -5,10 +5,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import MultipleSelector from "@/components/ui/multiselect";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Album, Artist, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, ThemeSeed } from "@/generated/prisma";
|
||||
import { Album, Artist, Category, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, Tag, ThemeSeed } from "@/generated/prisma";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { imageSchema } from "@/schemas/images/imageSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@ -28,6 +29,8 @@ type ImageWithItems = Image & {
|
||||
stats: ImageStats[],
|
||||
theme: ThemeSeed[],
|
||||
variants: ImageVariant[],
|
||||
tags: Tag[],
|
||||
categories: Category[],
|
||||
palettes: (
|
||||
ColorPalette & {
|
||||
items: ColorPaletteItem[]
|
||||
@ -35,7 +38,14 @@ type ImageWithItems = Image & {
|
||||
)[]
|
||||
};
|
||||
|
||||
export default function EditImageForm({ image, artists, albums }: { image: ImageWithItems, artists: Artist[], albums: Album[] }) {
|
||||
export default function EditImageForm({ image, artists, albums, tags, categories }:
|
||||
{
|
||||
image: ImageWithItems,
|
||||
artists: Artist[],
|
||||
albums: Album[],
|
||||
tags: Tag[],
|
||||
categories: Category[]
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof imageSchema>>({
|
||||
resolver: zodResolver(imageSchema),
|
||||
@ -56,6 +66,8 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
|
||||
|
||||
artistId: image.artist?.id || undefined,
|
||||
albumId: image.album?.id || undefined,
|
||||
tagIds: image.tags?.map(tag => tag.id) ?? [],
|
||||
categoryIds: image.categories?.map(cat => cat.id) ?? [],
|
||||
},
|
||||
})
|
||||
|
||||
@ -344,6 +356,70 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tagIds"
|
||||
render={({ field }) => {
|
||||
const selectedOptions = tags
|
||||
.filter(tag => field.value?.includes(tag.id))
|
||||
.map(tag => ({ label: tag.name, value: tag.id }));
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormControl>
|
||||
<MultipleSelector
|
||||
defaultOptions={tags.map(tag => ({
|
||||
label: tag.name,
|
||||
value: tag.id,
|
||||
}))}
|
||||
placeholder="Select tags"
|
||||
hidePlaceholderWhenSelected
|
||||
selectFirstItem
|
||||
value={selectedOptions}
|
||||
onChange={(options) => {
|
||||
const ids = options.map(option => option.value);
|
||||
field.onChange(ids);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryIds"
|
||||
render={({ field }) => {
|
||||
const selectedOptions = categories
|
||||
.filter(cat => field.value?.includes(cat.id))
|
||||
.map(cat => ({ label: cat.name, value: cat.id }));
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Categories</FormLabel>
|
||||
<FormControl>
|
||||
<MultipleSelector
|
||||
defaultOptions={categories.map(cat => ({
|
||||
label: cat.name,
|
||||
value: cat.id,
|
||||
}))}
|
||||
placeholder="Select categories"
|
||||
hidePlaceholderWhenSelected
|
||||
selectFirstItem
|
||||
value={selectedOptions}
|
||||
onChange={(options) => {
|
||||
const ids = options.map(option => option.value);
|
||||
field.onChange(ids);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
|
91
src/components/tags/edit/EditTagForm.tsx
Normal file
91
src/components/tags/edit/EditTagForm.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTag } from "@/actions/tags/deleteTag";
|
||||
import { updateTag } from "@/actions/tags/updateTag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tag } from "@/generated/prisma";
|
||||
import { tagSchema } from "@/schemas/tags/tagSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export default function EditTagForm({ tag }: { tag: Tag }) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof tagSchema>>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: {
|
||||
name: tag.name,
|
||||
description: tag.description || "",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof tagSchema>) {
|
||||
const updatedTag = await updateTag(values, tag.id)
|
||||
if (updatedTag) {
|
||||
toast.success("Tag updated")
|
||||
router.push(`/tags`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tag name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tag name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tag description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tag description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Description of the Category.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
await deleteTag(tag.id);
|
||||
toast.success("Tag deleted");
|
||||
router.push("/tags");
|
||||
}}
|
||||
>
|
||||
Delete Tag
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/components/tags/list/ListTags.tsx
Normal file
24
src/components/tags/list/ListTags.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tag } from "@/generated/prisma";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ListTags({ tags }: { tags: Tag[] }) {
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{tags.map((tag) => (
|
||||
<Link href={`/tags/edit/${tag.id}`} key={tag.id}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base truncate">{tag.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tag.description && <p className="text-sm text-muted-foreground">{tag.description}</p>}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
74
src/components/tags/new/CreateTagForm.tsx
Normal file
74
src/components/tags/new/CreateTagForm.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { createTag } from "@/actions/tags/createTag";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { tagSchema } from "@/schemas/tags/tagSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export default function CreateTagForm() {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof tagSchema>>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof tagSchema>) {
|
||||
const tag = await createTag(values)
|
||||
if (tag) {
|
||||
toast.success("Tag created")
|
||||
router.push(`/tags`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tag name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tag name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tag description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Tag description" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Description of the category.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
184
src/components/ui/command.tsx
Normal file
184
src/components/ui/command.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
608
src/components/ui/multiselect.tsx
Normal file
608
src/components/ui/multiselect.tsx
Normal file
@ -0,0 +1,608 @@
|
||||
'use client';
|
||||
|
||||
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
/**
|
||||
* sync search. This search will not showing loadingIndicator.
|
||||
* The rest props are the same as async search.
|
||||
* i.e.: creatable, groupBy, delay.
|
||||
**/
|
||||
onSearchSync?: (value: string) => Option[];
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
maxSelected?: number;
|
||||
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
||||
onMaxSelected?: (maxLimit: number) => void;
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
selectFirstItem?: boolean;
|
||||
/** Allow user to create option when there is no option matched. */
|
||||
creatable?: boolean;
|
||||
/** Props of `Command` */
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
'value' | 'placeholder' | 'disabled'
|
||||
>;
|
||||
/** hide the clear all button. */
|
||||
hideClearAllButton?: boolean;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: Option[];
|
||||
input: HTMLInputElement;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
if (options.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (!groupBy) {
|
||||
return {
|
||||
'': options,
|
||||
};
|
||||
}
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || '';
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [];
|
||||
}
|
||||
groupOption[key].push(option);
|
||||
});
|
||||
return groupOption;
|
||||
}
|
||||
|
||||
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
||||
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
||||
|
||||
for (const [key, value] of Object.entries(cloneOption)) {
|
||||
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
|
||||
}
|
||||
return cloneOption;
|
||||
}
|
||||
|
||||
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
||||
for (const [, value] of Object.entries(groupOption)) {
|
||||
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||
*
|
||||
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
||||
**/
|
||||
const CommandEmpty = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn('py-6 text-center text-sm', className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
defaultOptions: arrayDefaultOptions = [],
|
||||
options: arrayOptions,
|
||||
delay,
|
||||
onSearch,
|
||||
onSearchSync,
|
||||
loadingIndicator,
|
||||
emptyIndicator,
|
||||
maxSelected = Number.MAX_SAFE_INTEGER,
|
||||
onMaxSelected,
|
||||
hidePlaceholderWhenSelected,
|
||||
disabled,
|
||||
groupBy,
|
||||
className,
|
||||
badgeClassName,
|
||||
selectFirstItem = true,
|
||||
creatable = false,
|
||||
triggerSearchOnFocus = false,
|
||||
commandProps,
|
||||
inputProps,
|
||||
hideClearAllButton = false,
|
||||
}: MultipleSelectorProps,
|
||||
ref: React.Ref<MultipleSelectorRef>,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectedValue: [...selected],
|
||||
input: inputRef.current as HTMLInputElement,
|
||||
focus: () => inputRef?.current?.focus(),
|
||||
reset: () => setSelected([]),
|
||||
}),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnselect = React.useCallback(
|
||||
(option: Option) => {
|
||||
const newOptions = selected.filter((s) => s.value !== option.value);
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
},
|
||||
[onChange, selected],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If there is a last item and it is not fixed, we can remove it.
|
||||
if (lastSelectOption && !lastSelectOption.fixed) {
|
||||
handleUnselect(lastSelectOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is not a default behavior of the <input /> field
|
||||
if (e.key === 'Escape') {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleUnselect, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
/** If `onSearch` is provided, do not trigger options updated. */
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
|
||||
|
||||
useEffect(() => {
|
||||
/** sync search */
|
||||
|
||||
const doSearchSync = () => {
|
||||
const res = onSearchSync?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearchSync || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
doSearchSync();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
doSearchSync();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
/** async search */
|
||||
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearch || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
await doSearch();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
await doSearch();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
||||
selected.find((s) => s.value === inputValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Item = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value: string) => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, { value, label: value }];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
>
|
||||
{`Create "${inputValue}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// For normal creatable
|
||||
if (!onSearch && inputValue.length > 0) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
// For async search creatable. avoid showing creatable item before loading at first.
|
||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const EmptyItem = React.useCallback(() => {
|
||||
if (!emptyIndicator) return undefined;
|
||||
|
||||
// For async search that showing emptyIndicator
|
||||
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
||||
return (
|
||||
<CommandItem value="-" disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => removePickedOption(options, selected),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
}
|
||||
|
||||
if (creatable) {
|
||||
return (value: string, search: string) => {
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
// Using default filter in `cmdk`. We don't have to provide it.
|
||||
return undefined;
|
||||
}, [creatable, commandProps?.filter]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
filter={commandFilter()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
|
||||
{
|
||||
'px-3 py-2': selected.length !== 0,
|
||||
'cursor-text': !disabled && selected.length !== 0,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-wrap gap-1">
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
|
||||
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
(disabled || option.fixed) && 'hidden',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!onScrollbar) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
|
||||
className={cn(
|
||||
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
|
||||
{
|
||||
'w-full': hidePlaceholderWhenSelected,
|
||||
'px-3 py-2': selected.length === 0,
|
||||
'ml-1': selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(selected.filter((s) => s.fixed));
|
||||
onChange?.(selected.filter((s) => s.fixed));
|
||||
}}
|
||||
className={cn(
|
||||
'absolute ltr:right-0 rtl:left-0 h-6 w-6 p-0',
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length < 1 ||
|
||||
selected.filter((s) => s.fixed).length === selected.length) &&
|
||||
'hidden',
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
<CommandList
|
||||
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
|
||||
onMouseLeave={() => {
|
||||
setOnScrollbar(false);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setOnScrollbar(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>{loadingIndicator}</>
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
|
||||
<>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, option];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
option.disable && 'cursor-default text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = 'MultipleSelector';
|
||||
export default MultipleSelector;
|
7
src/schemas/categories/categorySchema.ts
Normal file
7
src/schemas/categories/categorySchema.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const categorySchema = z.object({
|
||||
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
@ -27,4 +27,6 @@ export const imageSchema = z.object({
|
||||
|
||||
artistId: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
categoryIds: z.array(z.string()).optional(),
|
||||
})
|
7
src/schemas/tags/tagSchema.ts
Normal file
7
src/schemas/tags/tagSchema.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const tagSchema = z.object({
|
||||
name: z.string().min(3, "Name is required. Min 3 characters."),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
@ -9,6 +9,17 @@ export function generatePaletteName(tones: Tone[]): string {
|
||||
return `palette-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export function generateColorName(hex: string): string {
|
||||
const hash = crypto.createHash("sha256").update(hex.toLowerCase()).digest("hex");
|
||||
return `color-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export function generateExtractColorName(hex: string, hue?: number, sat?: number, area?: number): string {
|
||||
const data = `${hex.toLowerCase()}-${hue ?? 0}-${sat ?? 0}-${area ?? 0}`;
|
||||
const hash = crypto.createHash("sha256").update(data).digest("hex");
|
||||
return `extract-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export function rgbToHex(rgb: number[]): string {
|
||||
return `#${rgb
|
||||
.map((val) => Math.round(val).toString(16).padStart(2, "0"))
|
||||
|
Reference in New Issue
Block a user