diff --git a/package-lock.json b/package-lock.json index 56343ba..3dc3ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 39fdadb..18a0da5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20250627223557_image_colors/migration.sql b/prisma/migrations/20250627223557_image_colors/migration.sql new file mode 100644 index 0000000..8b1224f --- /dev/null +++ b/prisma/migrations/20250627223557_image_colors/migration.sql @@ -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; diff --git a/prisma/migrations/20250627224409_image_colors/migration.sql b/prisma/migrations/20250627224409_image_colors/migration.sql new file mode 100644 index 0000000..98bff66 --- /dev/null +++ b/prisma/migrations/20250627224409_image_colors/migration.sql @@ -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"; diff --git a/prisma/migrations/20250627225348_image_category_tag/migration.sql b/prisma/migrations/20250627225348_image_category_tag/migration.sql new file mode 100644 index 0000000..0b2eee8 --- /dev/null +++ b/prisma/migrations/20250627225348_image_category_tag/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3321be7..b2f05ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,19 +103,19 @@ model Image { // sourceId String? // source Source? @relation(fields: [sourceId], references: [id]) - colors ImageColor[] - extractColors ExtractColor[] - metadata ImageMetadata[] - pixels PixelSummary[] - stats ImageStats[] - theme ThemeSeed[] - variants ImageVariant[] - // + metadata ImageMetadata[] + pixels PixelSummary[] + stats ImageStats[] + theme ThemeSeed[] + variants ImageVariant[] + // albumCover Album[] @relation("AlbumCoverImage") - // categories Category[] @relation("ImageCategories") // galleryCover Gallery[] @relation("GalleryCoverImage") - palettes ColorPalette[] @relation("ImagePalettes") - // tags Tag[] @relation("ImageTags") + categories Category[] @relation("ImageCategories") + colors ImageColor[] @relation("ImageToImageColor") + extractColors ExtractColor[] @relation("ImageToExtractColor") + palettes ColorPalette[] @relation("ImagePalettes") + tags Tag[] @relation("ImageTags") } model ImageMetadata { @@ -183,8 +183,8 @@ model ColorPalette { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - name String? - type String? + name String + type String items ColorPaletteItem[] images Image[] @relation("ImagePalettes") @@ -207,17 +207,17 @@ model ExtractColor { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - hex String - imageId String - blue Int - green Int - red Int + name String @unique + hex String + blue Int + green Int + red Int area Float? hue Float? saturation Float? - image Image @relation(fields: [imageId], references: [id]) + images Image[] @relation("ImageToExtractColor") } model ImageColor { @@ -225,15 +225,15 @@ model ImageColor { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - imageId String - type String + name String @unique + type String hex String? blue Int? green Int? red Int? - image Image @relation(fields: [imageId], references: [id]) + images Image[] @relation("ImageToImageColor") } model ThemeSeed { @@ -259,3 +259,27 @@ model PixelSummary { image Image @relation(fields: [imageId], references: [id]) } + +model Category { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + name String @unique + + description String? + + images Image[] @relation("ImageCategories") +} + +model Tag { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + name String @unique + + description String? + + images Image[] @relation("ImageTags") +} diff --git a/src/actions/categories/createCategory.ts b/src/actions/categories/createCategory.ts new file mode 100644 index 0000000..dd6f0b6 --- /dev/null +++ b/src/actions/categories/createCategory.ts @@ -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) { + return await prisma.category.create({ + data: { + name: values.name, + description: values.description + } + }) +} \ No newline at end of file diff --git a/src/actions/categories/deleteCategory.ts b/src/actions/categories/deleteCategory.ts new file mode 100644 index 0000000..dcca5e1 --- /dev/null +++ b/src/actions/categories/deleteCategory.ts @@ -0,0 +1,7 @@ +"use server"; + +import prisma from "@/lib/prisma"; + +export async function deleteCategory(id: string) { + await prisma.category.delete({ where: { id } }); +} \ No newline at end of file diff --git a/src/actions/categories/updateCategory.ts b/src/actions/categories/updateCategory.ts new file mode 100644 index 0000000..b834d34 --- /dev/null +++ b/src/actions/categories/updateCategory.ts @@ -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, + id: string +) { + return await prisma.category.update({ + where: { + id: id + }, + data: { + name: values.name, + description: values.description, + } + }) +} \ No newline at end of file diff --git a/src/actions/images/deleteImage.ts b/src/actions/images/deleteImage.ts index 64307f9..f3919cd 100644 --- a/src/actions/images/deleteImage.ts +++ b/src/actions/images/deleteImage.ts @@ -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 }; } \ No newline at end of file diff --git a/src/actions/images/extractColors.ts b/src/actions/images/extractColors.ts index 8d5c88c..abea538 100644 --- a/src/actions/images/extractColors.ts +++ b/src/actions/images/extractColors.ts @@ -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) { - const imageFile = values.file[0]; - const imageName = values.imageName; +// export async function uploadImage(values: z.infer) { +// const imageFile = values.file[0]; +// const imageName = values.imageName; - if (!(imageFile instanceof File)) { - console.log("No image or invalid type"); - return null; - } +// if (!(imageFile instanceof File)) { +// console.log("No image or invalid type"); +// return null; +// } - if (!imageName) { - console.log("No name for the image provided"); - return null; - } +// if (!imageName) { +// console.log("No name for the image provided"); +// return null; +// } - const fileName = imageFile.name; - const fileType = imageFile.type; - const fileSize = imageFile.size; - const lastModified = new Date(imageFile.lastModified); - const year = lastModified.getUTCFullYear(); - const month = lastModified.getUTCMonth() + 1; +// const fileName = imageFile.name; +// const fileType = imageFile.type; +// const fileSize = imageFile.size; +// const lastModified = new Date(imageFile.lastModified); +// const year = lastModified.getUTCFullYear(); +// const month = lastModified.getUTCMonth() + 1; - const fileKey = uuidv4(); +// const fileKey = uuidv4(); - const arrayBuffer = await imageFile.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); +// const arrayBuffer = await imageFile.arrayBuffer(); +// const buffer = Buffer.from(arrayBuffer); - const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`; +// const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`; - const originalKey = `original/${fileKey}.webp`; - const watermarkedKey = `watermarked/${fileKey}.webp`; - const resizedKey = `resized/${fileKey}.webp`; - const thumbnailKey = `thumbnails/${fileKey}.webp`; +// const originalKey = `original/${fileKey}.webp`; +// const watermarkedKey = `watermarked/${fileKey}.webp`; +// const resizedKey = `resized/${fileKey}.webp`; +// const thumbnailKey = `thumbnails/${fileKey}.webp`; - const sharpData = sharp(buffer); - const metadata = await sharpData.metadata(); - const stats = await sharpData.stats(); +// const sharpData = sharp(buffer); +// const metadata = await sharpData.metadata(); +// const stats = await sharpData.stats(); - const palette = await Vibrant.from(buffer).getPalette(); +// const palette = await Vibrant.from(buffer).getPalette(); - const vibrantHexes = Object.fromEntries( - Object.entries(palette).map(([key, swatch]) => { - const castSwatch = swatch as VibrantSwatch | null; - const rgb = castSwatch?._rgb; - const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); - return [key, hex]; - }) - ); +// const vibrantHexes = Object.fromEntries( +// Object.entries(palette).map(([key, swatch]) => { +// const castSwatch = swatch as VibrantSwatch | null; +// const rgb = castSwatch?._rgb; +// const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); +// return [key, hex]; +// }) +// ); - for (const [type, hex] of Object.entries(vibrantHexes)) { - if (!hex) continue; - const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); - await prisma.imageColor.create({ - data: { - type, - hex, - red: r, - green: g, - blue: b, - imageId: image.id, - }, - }); - } +// for (const [type, hex] of Object.entries(vibrantHexes)) { +// if (!hex) continue; +// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); +// await prisma.imageColor.create({ +// data: { +// type, +// hex, +// red: r, +// green: g, +// blue: b, +// imageId: image.id, +// }, +// }); +// } - const seedHex = - vibrantHexes.Vibrant ?? - vibrantHexes.Muted ?? - vibrantHexes.DarkVibrant ?? - vibrantHexes.DarkMuted ?? - vibrantHexes.LightVibrant ?? - vibrantHexes.LightMuted ?? - "#dfffff"; +// const seedHex = +// vibrantHexes.Vibrant ?? +// vibrantHexes.Muted ?? +// vibrantHexes.DarkVibrant ?? +// vibrantHexes.DarkMuted ?? +// vibrantHexes.LightVibrant ?? +// vibrantHexes.LightMuted ?? +// "#dfffff"; - const theme = themeFromSourceColor(argbFromHex(seedHex)); - const primaryTones = extractPaletteTones(theme.palettes.primary); - const secondaryTones = extractPaletteTones(theme.palettes.secondary); - const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); - const neutralTones = extractPaletteTones(theme.palettes.neutral); - const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); - const errorTones = extractPaletteTones(theme.palettes.error); +// const theme = themeFromSourceColor(argbFromHex(seedHex)); +// const primaryTones = extractPaletteTones(theme.palettes.primary); +// const secondaryTones = extractPaletteTones(theme.palettes.secondary); +// const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); +// const neutralTones = extractPaletteTones(theme.palettes.neutral); +// const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); +// const errorTones = extractPaletteTones(theme.palettes.error); - const pixels = await new Promise>((resolve, reject) => { - getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => { - if (err) reject(err); - else resolve(pixels); - }); - }); +// const pixels = await new Promise>((resolve, reject) => { +// getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => { +// if (err) reject(err); +// else resolve(pixels); +// }); +// }); - const extracted = await extractColors({ - data: Array.from(pixels.data), - width: pixels.shape[0], - height: pixels.shape[1] - }); +// const extracted = await extractColors({ +// data: Array.from(pixels.data), +// width: pixels.shape[0], +// height: pixels.shape[1] +// }); - //--- Original file - await s3.send( - new PutObjectCommand({ - Bucket: "felliesartapp", - Key: originalKey, - Body: buffer, - ContentType: "image/" + metadata.format, - }) - ); - //--- Watermarked file - const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); - const watermarkWidth = Math.round(metadata.width * 0.25); - const watermarkBuffer = await sharp(watermarkPath) - .resize({ width: watermarkWidth }) - .png() - .toBuffer(); - const watermarkedBuffer = await sharp(buffer) - .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) - .toFormat('webp') - .toBuffer() - const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); - await s3.send( - new PutObjectCommand({ - Bucket: "felliesartapp", - Key: watermarkedKey, - Body: watermarkedBuffer, - ContentType: "image/" + watermarkedMetadata.format, - }) - ); - //--- Resized file - const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400); - const resizedBuffer = await sharp(watermarkedBuffer) - .resize({ width: resizedWidth, withoutEnlargement: true }) - .toFormat('webp') - .toBuffer(); - const resizedMetadata = await sharp(resizedBuffer).metadata(); - await s3.send( - new PutObjectCommand({ - Bucket: "felliesartapp", - Key: resizedKey, - Body: resizedBuffer, - ContentType: "image/" + resizedMetadata.format, - }) - ); - //--- Thumbnail file - const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200); - const thumbnailBuffer = await sharp(watermarkedBuffer) - .resize({ width: thumbnailWidth, withoutEnlargement: true }) - .toFormat('webp') - .toBuffer(); - const thumbnailMetadata = await sharp(thumbnailBuffer).metadata(); - await s3.send( - new PutObjectCommand({ - Bucket: "felliesartapp", - Key: thumbnailKey, - Body: thumbnailBuffer, - ContentType: "image/" + thumbnailMetadata.format, - }) - ); +// //--- Original file +// await s3.send( +// new PutObjectCommand({ +// Bucket: "felliesartapp", +// Key: originalKey, +// Body: buffer, +// ContentType: "image/" + metadata.format, +// }) +// ); +// //--- Watermarked file +// const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); +// const watermarkWidth = Math.round(metadata.width * 0.25); +// const watermarkBuffer = await sharp(watermarkPath) +// .resize({ width: watermarkWidth }) +// .png() +// .toBuffer(); +// const watermarkedBuffer = await sharp(buffer) +// .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) +// .toFormat('webp') +// .toBuffer() +// const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); +// await s3.send( +// new PutObjectCommand({ +// Bucket: "felliesartapp", +// Key: watermarkedKey, +// Body: watermarkedBuffer, +// ContentType: "image/" + watermarkedMetadata.format, +// }) +// ); +// //--- Resized file +// const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400); +// const resizedBuffer = await sharp(watermarkedBuffer) +// .resize({ width: resizedWidth, withoutEnlargement: true }) +// .toFormat('webp') +// .toBuffer(); +// const resizedMetadata = await sharp(resizedBuffer).metadata(); +// await s3.send( +// new PutObjectCommand({ +// Bucket: "felliesartapp", +// Key: resizedKey, +// Body: resizedBuffer, +// ContentType: "image/" + resizedMetadata.format, +// }) +// ); +// //--- Thumbnail file +// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200); +// const thumbnailBuffer = await sharp(watermarkedBuffer) +// .resize({ width: thumbnailWidth, withoutEnlargement: true }) +// .toFormat('webp') +// .toBuffer(); +// const thumbnailMetadata = await sharp(thumbnailBuffer).metadata(); +// await s3.send( +// new PutObjectCommand({ +// Bucket: "felliesartapp", +// Key: thumbnailKey, +// Body: thumbnailBuffer, +// ContentType: "image/" + thumbnailMetadata.format, +// }) +// ); - const image = await prisma.image.create({ - data: { - imageName, - fileKey, - originalFile: fileName, - uploadDate: new Date(), +// const image = await prisma.image.create({ +// data: { +// imageName, +// fileKey, +// originalFile: fileName, +// uploadDate: new Date(), - creationDate: lastModified, - creationMonth: month, - creationYear: year, - imageData: imageDataUrl, - fileType: fileType, - fileSize: fileSize, - altText: "", - description: "", - }, - }); +// creationDate: lastModified, +// creationMonth: month, +// creationYear: year, +// imageData: imageDataUrl, +// fileType: fileType, +// fileSize: fileSize, +// altText: "", +// description: "", +// }, +// }); - await prisma.imageMetadata.create({ - data: { - imageId: image.id, - format: metadata.format || "unknown", - width: metadata.width || 0, - height: metadata.height || 0, - space: metadata.space || "unknown", - channels: metadata.channels || 0, - depth: metadata.depth || "unknown", - density: metadata.density ?? undefined, - bitsPerSample: metadata.bitsPerSample ?? undefined, - isProgressive: metadata.isProgressive ?? undefined, - isPalette: metadata.isPalette ?? undefined, - hasProfile: metadata.hasProfile ?? undefined, - hasAlpha: metadata.hasAlpha ?? undefined, - autoOrientW: metadata.autoOrient?.width ?? undefined, - autoOrientH: metadata.autoOrient?.height ?? undefined, - }, - }); +// await prisma.imageMetadata.create({ +// data: { +// imageId: image.id, +// format: metadata.format || "unknown", +// width: metadata.width || 0, +// height: metadata.height || 0, +// space: metadata.space || "unknown", +// channels: metadata.channels || 0, +// depth: metadata.depth || "unknown", +// density: metadata.density ?? undefined, +// bitsPerSample: metadata.bitsPerSample ?? undefined, +// isProgressive: metadata.isProgressive ?? undefined, +// isPalette: metadata.isPalette ?? undefined, +// hasProfile: metadata.hasProfile ?? undefined, +// hasAlpha: metadata.hasAlpha ?? undefined, +// autoOrientW: metadata.autoOrient?.width ?? undefined, +// autoOrientH: metadata.autoOrient?.height ?? undefined, +// }, +// }); - await prisma.imageStats.create({ - data: { - imageId: image.id, - isOpaque: stats.isOpaque, - entropy: stats.entropy, - sharpness: stats.sharpness, - dominantR: stats.dominant.r, - dominantG: stats.dominant.g, - dominantB: stats.dominant.b, - }, - }); +// await prisma.imageStats.create({ +// data: { +// imageId: image.id, +// isOpaque: stats.isOpaque, +// entropy: stats.entropy, +// sharpness: stats.sharpness, +// dominantR: stats.dominant.r, +// dominantG: stats.dominant.g, +// dominantB: stats.dominant.b, +// }, +// }); - await prisma.imageVariant.createMany({ - data: [ - { - s3Key: originalKey, - type: "original", - height: metadata.height, - width: metadata.width, - fileExtension: metadata.format, - mimeType: "image/" + metadata.format, - sizeBytes: metadata.size, - imageId: image.id - }, - { - s3Key: watermarkedKey, - type: "watermarked", - height: watermarkedMetadata.height, - width: watermarkedMetadata.width, - fileExtension: watermarkedMetadata.format, - mimeType: "image/" + watermarkedMetadata.format, - sizeBytes: watermarkedMetadata.size, - imageId: image.id - }, - { - s3Key: resizedKey, - type: "resized", - height: resizedMetadata.height, - width: resizedMetadata.width, - fileExtension: resizedMetadata.format, - mimeType: "image/" + resizedMetadata.format, - sizeBytes: resizedMetadata.size, - imageId: image.id - }, - { - s3Key: thumbnailKey, - type: "thumbnail", - height: thumbnailMetadata.height, - width: thumbnailMetadata.width, - fileExtension: thumbnailMetadata.format, - mimeType: "image/" + thumbnailMetadata.format, - sizeBytes: thumbnailMetadata.size, - imageId: image.id - } - ], - }); +// await prisma.imageVariant.createMany({ +// data: [ +// { +// s3Key: originalKey, +// type: "original", +// height: metadata.height, +// width: metadata.width, +// fileExtension: metadata.format, +// mimeType: "image/" + metadata.format, +// sizeBytes: metadata.size, +// imageId: image.id +// }, +// { +// s3Key: watermarkedKey, +// type: "watermarked", +// height: watermarkedMetadata.height, +// width: watermarkedMetadata.width, +// fileExtension: watermarkedMetadata.format, +// mimeType: "image/" + watermarkedMetadata.format, +// sizeBytes: watermarkedMetadata.size, +// imageId: image.id +// }, +// { +// s3Key: resizedKey, +// type: "resized", +// height: resizedMetadata.height, +// width: resizedMetadata.width, +// fileExtension: resizedMetadata.format, +// mimeType: "image/" + resizedMetadata.format, +// sizeBytes: resizedMetadata.size, +// imageId: image.id +// }, +// { +// s3Key: thumbnailKey, +// type: "thumbnail", +// height: thumbnailMetadata.height, +// width: thumbnailMetadata.width, +// fileExtension: thumbnailMetadata.format, +// mimeType: "image/" + thumbnailMetadata.format, +// sizeBytes: thumbnailMetadata.size, +// imageId: image.id +// } +// ], +// }); - await upsertPalettes(primaryTones, image.id, "primary"); - await upsertPalettes(secondaryTones, image.id, "secondary"); - await upsertPalettes(tertiaryTones, image.id, "tertiary"); - await upsertPalettes(neutralTones, image.id, "neutral"); - await upsertPalettes(neutralVariantTones, image.id, "neutralVariant"); - await upsertPalettes(errorTones, image.id, "error"); +// await upsertPalettes(primaryTones, image.id, "primary"); +// await upsertPalettes(secondaryTones, image.id, "secondary"); +// await upsertPalettes(tertiaryTones, image.id, "tertiary"); +// await upsertPalettes(neutralTones, image.id, "neutral"); +// await upsertPalettes(neutralVariantTones, image.id, "neutralVariant"); +// await upsertPalettes(errorTones, image.id, "error"); - for (const [type, hex] of Object.entries(vibrantHexes)) { - if (!hex) continue; - const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); - await prisma.imageColor.create({ - data: { - type, - hex, - red: r, - green: g, - blue: b, - imageId: image.id, - }, - }); - } +// for (const [type, hex] of Object.entries(vibrantHexes)) { +// if (!hex) continue; +// const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); +// await prisma.imageColor.create({ +// data: { +// type, +// hex, +// red: r, +// green: g, +// blue: b, +// imageId: image.id, +// }, +// }); +// } - for (const c of extracted) { - await prisma.extractColor.create({ - data: { - hex: c.hex, - red: c.red, - green: c.green, - blue: c.blue, - hue: c.hue, - saturation: c.saturation, - // value: c.value, - area: c.area, - // isLight: c.isLight, - imageId: image.id, - }, - }); - } +// for (const c of extracted) { +// await prisma.extractColor.create({ +// data: { +// hex: c.hex, +// red: c.red, +// green: c.green, +// blue: c.blue, +// hue: c.hue, +// saturation: c.saturation, +// // value: c.value, +// area: c.area, +// // isLight: c.isLight, +// imageId: image.id, +// }, +// }); +// } - await prisma.themeSeed.create({ - data: { - seedHex, - imageId: image.id, - }, - }); +// await prisma.themeSeed.create({ +// data: { +// seedHex, +// imageId: image.id, +// }, +// }); - await prisma.pixelSummary.create({ - data: { - width: pixels.shape[0], - height: pixels.shape[1], - channels: pixels.shape[2], - imageId: image.id, - }, - }); +// await prisma.pixelSummary.create({ +// data: { +// width: pixels.shape[0], +// height: pixels.shape[1], +// channels: pixels.shape[2], +// imageId: image.id, +// }, +// }); - return image - // return await prisma.gallery.create({ - // data: { - // name: values.name, - // slug: values.slug, - // description: values.description, - // } - // }) -} \ No newline at end of file +// return image +// // return await prisma.gallery.create({ +// // data: { +// // name: values.name, +// // slug: values.slug, +// // description: values.description, +// // } +// // }) +// } \ No newline at end of file diff --git a/src/actions/images/generateExtractColors.ts b/src/actions/images/generateExtractColors.ts index 042a945..d29d3a0 100644 --- a/src/actions/images/generateExtractColors.ts +++ b/src/actions/images/generateExtractColors.ts @@ -2,6 +2,7 @@ import prisma from "@/lib/prisma"; import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; +import { generateExtractColorName } from "@/utils/uploadHelper"; import { extractColors } from "extract-colors"; import getPixels from "get-pixels"; import { NdArray } from "ndarray"; @@ -34,21 +35,35 @@ export async function generateExtractColors(imageId: string, fileKey: string) { }); for (const c of extracted) { - await prisma.extractColor.create({ + const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area); + + await prisma.image.update({ + where: { id: imageId }, data: { - hex: c.hex, - red: c.red, - green: c.green, - blue: c.blue, - hue: c.hue, - saturation: c.saturation, - area: c.area, - imageId: imageId, + extractColors: { + connectOrCreate: { + where: { name }, + create: { + name, + hex: c.hex, + red: c.red, + green: c.green, + blue: c.blue, + hue: c.hue, + saturation: c.saturation, + area: c.area, + }, + }, + }, }, }); } return await prisma.extractColor.findMany({ - where: { imageId: imageId } + where: { + images: { + some: { id: imageId }, + }, + }, }); } \ No newline at end of file diff --git a/src/actions/images/generateImageColors.ts b/src/actions/images/generateImageColors.ts index 905ccea..6eebcec 100644 --- a/src/actions/images/generateImageColors.ts +++ b/src/actions/images/generateImageColors.ts @@ -3,7 +3,7 @@ import prisma from "@/lib/prisma"; import { VibrantSwatch } from "@/types/VibrantSwatch"; import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; -import { rgbToHex } from "@/utils/uploadHelper"; +import { generateColorName, rgbToHex } from "@/utils/uploadHelper"; import { Vibrant } from "node-vibrant/node"; export async function generateImageColors(imageId: string, fileKey: string) { @@ -22,19 +22,33 @@ export async function generateImageColors(imageId: string, fileKey: string) { for (const [type, hex] of Object.entries(vibrantHexes)) { if (!hex) continue; const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); - await prisma.imageColor.create({ + const name = generateColorName(hex); + + await prisma.image.update({ + where: { id: imageId }, data: { - type, - hex, - red: r, - green: g, - blue: b, - imageId: imageId, + colors: { + connectOrCreate: { + where: { name: name }, + create: { + name: name, + type: type, + hex: hex, + red: r, + green: g, + blue: b, + } + } + } }, }); } return await prisma.imageColor.findMany({ - where: { imageId: imageId } + where: { + images: { + some: { id: imageId }, + }, + }, }); } \ No newline at end of file diff --git a/src/actions/tags/createTag.ts b/src/actions/tags/createTag.ts new file mode 100644 index 0000000..052817d --- /dev/null +++ b/src/actions/tags/createTag.ts @@ -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) { + return await prisma.tag.create({ + data: { + name: values.name, + description: values.description + } + }) +} \ No newline at end of file diff --git a/src/actions/tags/deleteTag.ts b/src/actions/tags/deleteTag.ts new file mode 100644 index 0000000..83eb068 --- /dev/null +++ b/src/actions/tags/deleteTag.ts @@ -0,0 +1,7 @@ +"use server"; + +import prisma from "@/lib/prisma"; + +export async function deleteTag(id: string) { + await prisma.tag.delete({ where: { id } }); +} \ No newline at end of file diff --git a/src/actions/tags/updateTag.ts b/src/actions/tags/updateTag.ts new file mode 100644 index 0000000..dab5f02 --- /dev/null +++ b/src/actions/tags/updateTag.ts @@ -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, + id: string +) { + return await prisma.tag.update({ + where: { + id: id + }, + data: { + name: values.name, + description: values.description, + } + }) +} \ No newline at end of file diff --git a/src/app/categories/edit/[id]/page.tsx b/src/app/categories/edit/[id]/page.tsx new file mode 100644 index 0000000..7beebc8 --- /dev/null +++ b/src/app/categories/edit/[id]/page.tsx @@ -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 ( +
+

Edit category

+ {cat ? : 'Category not found...'} +
+ ); +} \ No newline at end of file diff --git a/src/app/categories/new/page.tsx b/src/app/categories/new/page.tsx new file mode 100644 index 0000000..90e287a --- /dev/null +++ b/src/app/categories/new/page.tsx @@ -0,0 +1,10 @@ +import CreateCategoryForm from "@/components/categories/new/CreateCategoryForm"; + +export default async function CategoriesNewPage() { + return ( +
+

New category

+ +
+ ); +} \ No newline at end of file diff --git a/src/app/categories/page.tsx b/src/app/categories/page.tsx new file mode 100644 index 0000000..a971250 --- /dev/null +++ b/src/app/categories/page.tsx @@ -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 ( +
+
+

Categories

+ + Add new Category + +
+ {categories.length > 0 ? :

No categories found.

} +
+ ); +} \ No newline at end of file diff --git a/src/app/images/edit/[id]/page.tsx b/src/app/images/edit/[id]/page.tsx index 358beb0..c74b141 100644 --- a/src/app/images/edit/[id]/page.tsx +++ b/src/app/images/edit/[id]/page.tsx @@ -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 (

Edit image

- {image ? : 'Image not found...'} + {image ? : 'Image not found...'} +
+ {image && } +
{image && }
diff --git a/src/app/tags/edit/[id]/page.tsx b/src/app/tags/edit/[id]/page.tsx new file mode 100644 index 0000000..0bb5a3e --- /dev/null +++ b/src/app/tags/edit/[id]/page.tsx @@ -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 ( +
+

Edit tag

+ {tag ? : 'Tag not found...'} +
+ ); +} \ No newline at end of file diff --git a/src/app/tags/new/page.tsx b/src/app/tags/new/page.tsx new file mode 100644 index 0000000..de53681 --- /dev/null +++ b/src/app/tags/new/page.tsx @@ -0,0 +1,10 @@ +import CreateTagForm from "@/components/tags/new/CreateTagForm"; + +export default async function TagsNewPage() { + return ( +
+

New tag

+ +
+ ); +} \ No newline at end of file diff --git a/src/app/tags/page.tsx b/src/app/tags/page.tsx new file mode 100644 index 0000000..5afb374 --- /dev/null +++ b/src/app/tags/page.tsx @@ -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 ( +
+
+

Tags

+ + Add new Tag + +
+ {tags.length > 0 ? :

No tags found.

} +
+ ); +} \ No newline at end of file diff --git a/src/components/categories/edit/EditCategoryForm.tsx b/src/components/categories/edit/EditCategoryForm.tsx new file mode 100644 index 0000000..779f466 --- /dev/null +++ b/src/components/categories/edit/EditCategoryForm.tsx @@ -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>({ + resolver: zodResolver(categorySchema), + defaultValues: { + name: category.name, + description: category.description || "", + }, + }) + + async function onSubmit(values: z.infer) { + const updatedCategory = await updateCategory(values, category.id) + if (updatedCategory) { + toast.success("Category updated") + router.push(`/categories`) + } + } + + return ( +
+
+ + ( + + Category name + + + + + This is your public display name. + + + + )} + /> + ( + + Category description (optional) + + + + + Description of the Category. + + + + )} + /> +
+ + +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/categories/list/ListCategories.tsx b/src/components/categories/list/ListCategories.tsx new file mode 100644 index 0000000..a70e795 --- /dev/null +++ b/src/components/categories/list/ListCategories.tsx @@ -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 ( +
+ {categories.map((cat) => ( + + + + {cat.name} + + + {cat.description &&

{cat.description}

} +
+ + +
+ + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/categories/new/CreateCategoryForm.tsx b/src/components/categories/new/CreateCategoryForm.tsx new file mode 100644 index 0000000..d7e0061 --- /dev/null +++ b/src/components/categories/new/CreateCategoryForm.tsx @@ -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>({ + resolver: zodResolver(categorySchema), + defaultValues: { + name: "", + description: "" + }, + }) + + async function onSubmit(values: z.infer) { + const cat = await createCategory(values) + if (cat) { + toast.success("Category created") + router.push(`/categories`) + } + } + + return ( +
+ + ( + + Category name + + + + + This is your public display name. + + + + )} + /> + ( + + Category description + + + + + Description of the category. + + + + )} + /> +
+ + +
+ + + ); +} \ No newline at end of file diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 12e12eb..0890359 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -27,6 +27,16 @@ export default function TopNav() { Artists + + + Categories + + + + + Tags + + Images diff --git a/src/components/images/edit/DeleteImageButton.tsx b/src/components/images/edit/DeleteImageButton.tsx new file mode 100644 index 0000000..8baf1a6 --- /dev/null +++ b/src/components/images/edit/DeleteImageButton.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/components/images/edit/EditImageForm.tsx b/src/components/images/edit/EditImageForm.tsx index 3fffc3b..0229b11 100644 --- a/src/components/images/edit/EditImageForm.tsx +++ b/src/components/images/edit/EditImageForm.tsx @@ -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>({ resolver: zodResolver(imageSchema), @@ -56,6 +66,8 @@ export default function EditImageForm({ image, artists, albums }: { image: Image artistId: image.artist?.id || undefined, albumId: image.album?.id || undefined, + tagIds: image.tags?.map(tag => tag.id) ?? [], + categoryIds: image.categories?.map(cat => cat.id) ?? [], }, }) @@ -344,12 +356,76 @@ export default function EditImageForm({ image, artists, albums }: { image: Image )} /> + { + const selectedOptions = tags + .filter(tag => field.value?.includes(tag.id)) + .map(tag => ({ label: tag.name, value: tag.id })); + return ( + + Tags + + ({ + 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); + }} + /> + + + + ) + }} + /> + + { + const selectedOptions = categories + .filter(cat => field.value?.includes(cat.id)) + .map(cat => ({ label: cat.name, value: cat.id })); + return ( + + Categories + + ({ + 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); + }} + /> + + + + ) + }} + /> +
-
+
); } \ No newline at end of file diff --git a/src/components/tags/edit/EditTagForm.tsx b/src/components/tags/edit/EditTagForm.tsx new file mode 100644 index 0000000..69fc2a0 --- /dev/null +++ b/src/components/tags/edit/EditTagForm.tsx @@ -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>({ + resolver: zodResolver(tagSchema), + defaultValues: { + name: tag.name, + description: tag.description || "", + }, + }) + + async function onSubmit(values: z.infer) { + const updatedTag = await updateTag(values, tag.id) + if (updatedTag) { + toast.success("Tag updated") + router.push(`/tags`) + } + } + + return ( +
+
+ + ( + + Tag name + + + + + This is your public display name. + + + + )} + /> + ( + + Tag description (optional) + + + + + Description of the Category. + + + + )} + /> +
+ + +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/tags/list/ListTags.tsx b/src/components/tags/list/ListTags.tsx new file mode 100644 index 0000000..949672a --- /dev/null +++ b/src/components/tags/list/ListTags.tsx @@ -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 ( +
+ {tags.map((tag) => ( + + + + {tag.name} + + + {tag.description &&

{tag.description}

} +
+ + +
+ + ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/tags/new/CreateTagForm.tsx b/src/components/tags/new/CreateTagForm.tsx new file mode 100644 index 0000000..b737c70 --- /dev/null +++ b/src/components/tags/new/CreateTagForm.tsx @@ -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>({ + resolver: zodResolver(tagSchema), + defaultValues: { + name: "", + description: "" + }, + }) + + async function onSubmit(values: z.infer) { + const tag = await createTag(values) + if (tag) { + toast.success("Tag created") + router.push(`/tags`) + } + } + + return ( +
+ + ( + + Tag name + + + + + This is your public display name. + + + + )} + /> + ( + + Tag description + + + + + Description of the category. + + + + )} + /> +
+ + +
+ + + ); +} \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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 & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -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) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/multiselect.tsx b/src/components/ui/multiselect.tsx new file mode 100644 index 0000000..f4ca3d9 --- /dev/null +++ b/src/components/ui/multiselect.tsx @@ -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; + /** + * 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; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + 'value' | 'placeholder' | 'disabled' + >; + /** hide the clear all button. */ + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; + focus: () => void; + reset: () => void; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(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 +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = 'CommandEmpty'; + +const MultipleSelector = React.forwardRef( + ( + { + 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, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + 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) => { + 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 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 = ( + { + 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}"`} + + ); + + // 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 ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => 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 ( + { + 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()} + > +
{ + if (disabled) return; + inputRef?.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + 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, + )} + /> + +
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef?.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && } + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + 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} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = 'MultipleSelector'; +export default MultipleSelector; diff --git a/src/schemas/categories/categorySchema.ts b/src/schemas/categories/categorySchema.ts new file mode 100644 index 0000000..17484c1 --- /dev/null +++ b/src/schemas/categories/categorySchema.ts @@ -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(), +}) + diff --git a/src/schemas/images/imageSchema.ts b/src/schemas/images/imageSchema.ts index ad6b6c5..33845b5 100644 --- a/src/schemas/images/imageSchema.ts +++ b/src/schemas/images/imageSchema.ts @@ -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(), }) \ No newline at end of file diff --git a/src/schemas/tags/tagSchema.ts b/src/schemas/tags/tagSchema.ts new file mode 100644 index 0000000..1569dd2 --- /dev/null +++ b/src/schemas/tags/tagSchema.ts @@ -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(), +}) + diff --git a/src/utils/uploadHelper.ts b/src/utils/uploadHelper.ts index 94b05f4..2163bc5 100644 --- a/src/utils/uploadHelper.ts +++ b/src/utils/uploadHelper.ts @@ -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"))