Add tags and categories CRUD

This commit is contained in:
2025-06-28 01:39:45 +02:00
parent 9f0807b0fc
commit 21bcee6cad
40 changed files with 2346 additions and 349 deletions

54
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@material/material-color-utilities": "^0.3.0", "@material/material-color-utilities": "^0.3.0",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.10.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-navigation-menu": "^1.2.13",
@ -21,6 +22,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"extract-colors": "^4.2.0", "extract-colors": "^4.2.0",
"get-pixels": "^3.3.3", "get-pixels": "^3.3.3",
@ -2294,6 +2296,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10",
"@radix-ui/react-focus-guards": "1.1.2",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@ -5361,6 +5399,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",

View File

@ -14,6 +14,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@material/material-color-utilities": "^0.3.0", "@material/material-color-utilities": "^0.3.0",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.10.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13", "@radix-ui/react-navigation-menu": "^1.2.13",
@ -22,6 +23,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"extract-colors": "^4.2.0", "extract-colors": "^4.2.0",
"get-pixels": "^3.3.3", "get-pixels": "^3.3.3",

View 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;

View File

@ -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";

View File

@ -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;

View File

@ -103,19 +103,19 @@ model Image {
// sourceId String? // sourceId String?
// source Source? @relation(fields: [sourceId], references: [id]) // source Source? @relation(fields: [sourceId], references: [id])
colors ImageColor[] metadata ImageMetadata[]
extractColors ExtractColor[] pixels PixelSummary[]
metadata ImageMetadata[] stats ImageStats[]
pixels PixelSummary[] theme ThemeSeed[]
stats ImageStats[] variants ImageVariant[]
theme ThemeSeed[]
variants ImageVariant[]
//
// albumCover Album[] @relation("AlbumCoverImage") // albumCover Album[] @relation("AlbumCoverImage")
// categories Category[] @relation("ImageCategories")
// galleryCover Gallery[] @relation("GalleryCoverImage") // galleryCover Gallery[] @relation("GalleryCoverImage")
palettes ColorPalette[] @relation("ImagePalettes") categories Category[] @relation("ImageCategories")
// tags Tag[] @relation("ImageTags") colors ImageColor[] @relation("ImageToImageColor")
extractColors ExtractColor[] @relation("ImageToExtractColor")
palettes ColorPalette[] @relation("ImagePalettes")
tags Tag[] @relation("ImageTags")
} }
model ImageMetadata { model ImageMetadata {
@ -183,8 +183,8 @@ model ColorPalette {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
name String? name String
type String? type String
items ColorPaletteItem[] items ColorPaletteItem[]
images Image[] @relation("ImagePalettes") images Image[] @relation("ImagePalettes")
@ -207,17 +207,17 @@ model ExtractColor {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
hex String name String @unique
imageId String hex String
blue Int blue Int
green Int green Int
red Int red Int
area Float? area Float?
hue Float? hue Float?
saturation Float? saturation Float?
image Image @relation(fields: [imageId], references: [id]) images Image[] @relation("ImageToExtractColor")
} }
model ImageColor { model ImageColor {
@ -225,15 +225,15 @@ model ImageColor {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
imageId String name String @unique
type String type String
hex String? hex String?
blue Int? blue Int?
green Int? green Int?
red Int? red Int?
image Image @relation(fields: [imageId], references: [id]) images Image[] @relation("ImageToImageColor")
} }
model ThemeSeed { model ThemeSeed {
@ -259,3 +259,27 @@ model PixelSummary {
image Image @relation(fields: [imageId], references: [id]) image Image @relation(fields: [imageId], references: [id])
} }
model Category {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
description String?
images Image[] @relation("ImageCategories")
}
model Tag {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
description String?
images Image[] @relation("ImageTags")
}

View 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
}
})
}

View File

@ -0,0 +1,7 @@
"use server";
import prisma from "@/lib/prisma";
export async function deleteCategory(id: string) {
await prisma.category.delete({ where: { id } });
}

View 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,
}
})
}

View File

@ -1,7 +1,64 @@
"use server"; "use server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
export async function deleteImage(id: string) { export async function deleteImage(imageId: string) {
await prisma.gallery.delete({ where: { id } }); const image = await prisma.image.findUnique({
where: { id: imageId },
include: {
variants: true,
palettes: { include: { items: true } },
colors: true,
extractColors: true,
theme: true,
metadata: true,
pixels: true,
stats: true,
},
});
if (!image) throw new Error("Image not found");
// Delete S3 objects
for (const variant of image.variants) {
await s3.send(new DeleteObjectCommand({
Bucket: "felliesartapp",
Key: variant.s3Key,
}));
}
// Delete image variants
await prisma.imageVariant.deleteMany({ where: { imageId } });
// Delete extract colors
await prisma.extractColor.deleteMany({ where: { imageId } });
// Delete image colors
await prisma.imageColor.deleteMany({ where: { imageId } });
// Delete palettes (and items only if no other image uses them)
const palettes = await prisma.colorPalette.findMany({
where: { images: { some: { id: imageId } } },
include: { images: { select: { id: true } }, items: true }
});
for (const palette of palettes) {
if (palette.images.length === 1 && palette.images[0].id === imageId) {
await prisma.colorPaletteItem.deleteMany({ where: { colorPaletteId: palette.id } });
await prisma.colorPalette.delete({ where: { id: palette.id } });
}
}
// Delete metadata-related entries
await prisma.imageMetadata.deleteMany({ where: { imageId } });
await prisma.imageStats.deleteMany({ where: { imageId } });
await prisma.pixelSummary.deleteMany({ where: { imageId } });
await prisma.themeSeed.deleteMany({ where: { imageId } });
// Finally delete the image
await prisma.image.delete({ where: { id: imageId } });
return { success: true };
} }

View File

@ -1,330 +1,330 @@
"use server" // "use server"
import prisma from "@/lib/prisma"; // import prisma from "@/lib/prisma";
import { s3 } from "@/lib/s3"; // import { s3 } from "@/lib/s3";
import { imageUploadSchema } from "@/schemas/images/imageSchema"; // import { imageUploadSchema } from "@/schemas/images/imageSchema";
import { VibrantSwatch } from "@/types/VibrantSwatch"; // import { VibrantSwatch } from "@/types/VibrantSwatch";
import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper"; // import { extractPaletteTones, rgbToHex, upsertPalettes } from "@/utils/uploadHelper";
import { PutObjectCommand } from "@aws-sdk/client-s3"; // import { PutObjectCommand } from "@aws-sdk/client-s3";
import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities"; // import { argbFromHex, themeFromSourceColor } from "@material/material-color-utilities";
import { extractColors } from "extract-colors"; // import { extractColors } from "extract-colors";
import getPixels from "get-pixels"; // import getPixels from "get-pixels";
import { NdArray } from "ndarray"; // import { NdArray } from "ndarray";
import { Vibrant } from "node-vibrant/node"; // import { Vibrant } from "node-vibrant/node";
import path from "path"; // import path from "path";
import sharp from "sharp"; // import sharp from "sharp";
import { v4 as uuidv4 } from 'uuid'; // import { v4 as uuidv4 } from 'uuid';
import * as z from "zod/v4"; // import * as z from "zod/v4";
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) { // export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
const imageFile = values.file[0]; // const imageFile = values.file[0];
const imageName = values.imageName; // const imageName = values.imageName;
if (!(imageFile instanceof File)) { // if (!(imageFile instanceof File)) {
console.log("No image or invalid type"); // console.log("No image or invalid type");
return null; // return null;
} // }
if (!imageName) { // if (!imageName) {
console.log("No name for the image provided"); // console.log("No name for the image provided");
return null; // return null;
} // }
const fileName = imageFile.name; // const fileName = imageFile.name;
const fileType = imageFile.type; // const fileType = imageFile.type;
const fileSize = imageFile.size; // const fileSize = imageFile.size;
const lastModified = new Date(imageFile.lastModified); // const lastModified = new Date(imageFile.lastModified);
const year = lastModified.getUTCFullYear(); // const year = lastModified.getUTCFullYear();
const month = lastModified.getUTCMonth() + 1; // const month = lastModified.getUTCMonth() + 1;
const fileKey = uuidv4(); // const fileKey = uuidv4();
const arrayBuffer = await imageFile.arrayBuffer(); // const arrayBuffer = await imageFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); // const buffer = Buffer.from(arrayBuffer);
const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`; // const imageDataUrl = `data:${imageFile.type};base64,${buffer.toString("base64")}`;
const originalKey = `original/${fileKey}.webp`; // const originalKey = `original/${fileKey}.webp`;
const watermarkedKey = `watermarked/${fileKey}.webp`; // const watermarkedKey = `watermarked/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`; // const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnails/${fileKey}.webp`; // const thumbnailKey = `thumbnails/${fileKey}.webp`;
const sharpData = sharp(buffer); // const sharpData = sharp(buffer);
const metadata = await sharpData.metadata(); // const metadata = await sharpData.metadata();
const stats = await sharpData.stats(); // const stats = await sharpData.stats();
const palette = await Vibrant.from(buffer).getPalette(); // const palette = await Vibrant.from(buffer).getPalette();
const vibrantHexes = Object.fromEntries( // const vibrantHexes = Object.fromEntries(
Object.entries(palette).map(([key, swatch]) => { // Object.entries(palette).map(([key, swatch]) => {
const castSwatch = swatch as VibrantSwatch | null; // const castSwatch = swatch as VibrantSwatch | null;
const rgb = castSwatch?._rgb; // const rgb = castSwatch?._rgb;
const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); // const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
return [key, hex]; // return [key, hex];
}) // })
); // );
for (const [type, hex] of Object.entries(vibrantHexes)) { // for (const [type, hex] of Object.entries(vibrantHexes)) {
if (!hex) continue; // if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); // const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
await prisma.imageColor.create({ // await prisma.imageColor.create({
data: { // data: {
type, // type,
hex, // hex,
red: r, // red: r,
green: g, // green: g,
blue: b, // blue: b,
imageId: image.id, // imageId: image.id,
}, // },
}); // });
} // }
const seedHex = // const seedHex =
vibrantHexes.Vibrant ?? // vibrantHexes.Vibrant ??
vibrantHexes.Muted ?? // vibrantHexes.Muted ??
vibrantHexes.DarkVibrant ?? // vibrantHexes.DarkVibrant ??
vibrantHexes.DarkMuted ?? // vibrantHexes.DarkMuted ??
vibrantHexes.LightVibrant ?? // vibrantHexes.LightVibrant ??
vibrantHexes.LightMuted ?? // vibrantHexes.LightMuted ??
"#dfffff"; // "#dfffff";
const theme = themeFromSourceColor(argbFromHex(seedHex)); // const theme = themeFromSourceColor(argbFromHex(seedHex));
const primaryTones = extractPaletteTones(theme.palettes.primary); // const primaryTones = extractPaletteTones(theme.palettes.primary);
const secondaryTones = extractPaletteTones(theme.palettes.secondary); // const secondaryTones = extractPaletteTones(theme.palettes.secondary);
const tertiaryTones = extractPaletteTones(theme.palettes.tertiary); // const tertiaryTones = extractPaletteTones(theme.palettes.tertiary);
const neutralTones = extractPaletteTones(theme.palettes.neutral); // const neutralTones = extractPaletteTones(theme.palettes.neutral);
const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant); // const neutralVariantTones = extractPaletteTones(theme.palettes.neutralVariant);
const errorTones = extractPaletteTones(theme.palettes.error); // const errorTones = extractPaletteTones(theme.palettes.error);
const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => { // const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => { // getPixels(imageDataUrl, 'image/' + metadata.format || "image/jpeg", (err, pixels) => {
if (err) reject(err); // if (err) reject(err);
else resolve(pixels); // else resolve(pixels);
}); // });
}); // });
const extracted = await extractColors({ // const extracted = await extractColors({
data: Array.from(pixels.data), // data: Array.from(pixels.data),
width: pixels.shape[0], // width: pixels.shape[0],
height: pixels.shape[1] // height: pixels.shape[1]
}); // });
//--- Original file // //--- Original file
await s3.send( // await s3.send(
new PutObjectCommand({ // new PutObjectCommand({
Bucket: "felliesartapp", // Bucket: "felliesartapp",
Key: originalKey, // Key: originalKey,
Body: buffer, // Body: buffer,
ContentType: "image/" + metadata.format, // ContentType: "image/" + metadata.format,
}) // })
); // );
//--- Watermarked file // //--- Watermarked file
const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg'); // const watermarkPath = path.join(process.cwd(), 'public/watermark/fellies-watermark.svg');
const watermarkWidth = Math.round(metadata.width * 0.25); // const watermarkWidth = Math.round(metadata.width * 0.25);
const watermarkBuffer = await sharp(watermarkPath) // const watermarkBuffer = await sharp(watermarkPath)
.resize({ width: watermarkWidth }) // .resize({ width: watermarkWidth })
.png() // .png()
.toBuffer(); // .toBuffer();
const watermarkedBuffer = await sharp(buffer) // const watermarkedBuffer = await sharp(buffer)
.composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }]) // .composite([{ input: watermarkBuffer, gravity: 'southwest', blend: 'atop' }])
.toFormat('webp') // .toFormat('webp')
.toBuffer() // .toBuffer()
const watermarkedMetadata = await sharp(watermarkedBuffer).metadata(); // const watermarkedMetadata = await sharp(watermarkedBuffer).metadata();
await s3.send( // await s3.send(
new PutObjectCommand({ // new PutObjectCommand({
Bucket: "felliesartapp", // Bucket: "felliesartapp",
Key: watermarkedKey, // Key: watermarkedKey,
Body: watermarkedBuffer, // Body: watermarkedBuffer,
ContentType: "image/" + watermarkedMetadata.format, // ContentType: "image/" + watermarkedMetadata.format,
}) // })
); // );
//--- Resized file // //--- Resized file
const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400); // const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
const resizedBuffer = await sharp(watermarkedBuffer) // const resizedBuffer = await sharp(watermarkedBuffer)
.resize({ width: resizedWidth, withoutEnlargement: true }) // .resize({ width: resizedWidth, withoutEnlargement: true })
.toFormat('webp') // .toFormat('webp')
.toBuffer(); // .toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata(); // const resizedMetadata = await sharp(resizedBuffer).metadata();
await s3.send( // await s3.send(
new PutObjectCommand({ // new PutObjectCommand({
Bucket: "felliesartapp", // Bucket: "felliesartapp",
Key: resizedKey, // Key: resizedKey,
Body: resizedBuffer, // Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format, // ContentType: "image/" + resizedMetadata.format,
}) // })
); // );
//--- Thumbnail file // //--- Thumbnail file
const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200); // const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
const thumbnailBuffer = await sharp(watermarkedBuffer) // const thumbnailBuffer = await sharp(watermarkedBuffer)
.resize({ width: thumbnailWidth, withoutEnlargement: true }) // .resize({ width: thumbnailWidth, withoutEnlargement: true })
.toFormat('webp') // .toFormat('webp')
.toBuffer(); // .toBuffer();
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata(); // const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
await s3.send( // await s3.send(
new PutObjectCommand({ // new PutObjectCommand({
Bucket: "felliesartapp", // Bucket: "felliesartapp",
Key: thumbnailKey, // Key: thumbnailKey,
Body: thumbnailBuffer, // Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format, // ContentType: "image/" + thumbnailMetadata.format,
}) // })
); // );
const image = await prisma.image.create({ // const image = await prisma.image.create({
data: { // data: {
imageName, // imageName,
fileKey, // fileKey,
originalFile: fileName, // originalFile: fileName,
uploadDate: new Date(), // uploadDate: new Date(),
creationDate: lastModified, // creationDate: lastModified,
creationMonth: month, // creationMonth: month,
creationYear: year, // creationYear: year,
imageData: imageDataUrl, // imageData: imageDataUrl,
fileType: fileType, // fileType: fileType,
fileSize: fileSize, // fileSize: fileSize,
altText: "", // altText: "",
description: "", // description: "",
}, // },
}); // });
await prisma.imageMetadata.create({ // await prisma.imageMetadata.create({
data: { // data: {
imageId: image.id, // imageId: image.id,
format: metadata.format || "unknown", // format: metadata.format || "unknown",
width: metadata.width || 0, // width: metadata.width || 0,
height: metadata.height || 0, // height: metadata.height || 0,
space: metadata.space || "unknown", // space: metadata.space || "unknown",
channels: metadata.channels || 0, // channels: metadata.channels || 0,
depth: metadata.depth || "unknown", // depth: metadata.depth || "unknown",
density: metadata.density ?? undefined, // density: metadata.density ?? undefined,
bitsPerSample: metadata.bitsPerSample ?? undefined, // bitsPerSample: metadata.bitsPerSample ?? undefined,
isProgressive: metadata.isProgressive ?? undefined, // isProgressive: metadata.isProgressive ?? undefined,
isPalette: metadata.isPalette ?? undefined, // isPalette: metadata.isPalette ?? undefined,
hasProfile: metadata.hasProfile ?? undefined, // hasProfile: metadata.hasProfile ?? undefined,
hasAlpha: metadata.hasAlpha ?? undefined, // hasAlpha: metadata.hasAlpha ?? undefined,
autoOrientW: metadata.autoOrient?.width ?? undefined, // autoOrientW: metadata.autoOrient?.width ?? undefined,
autoOrientH: metadata.autoOrient?.height ?? undefined, // autoOrientH: metadata.autoOrient?.height ?? undefined,
}, // },
}); // });
await prisma.imageStats.create({ // await prisma.imageStats.create({
data: { // data: {
imageId: image.id, // imageId: image.id,
isOpaque: stats.isOpaque, // isOpaque: stats.isOpaque,
entropy: stats.entropy, // entropy: stats.entropy,
sharpness: stats.sharpness, // sharpness: stats.sharpness,
dominantR: stats.dominant.r, // dominantR: stats.dominant.r,
dominantG: stats.dominant.g, // dominantG: stats.dominant.g,
dominantB: stats.dominant.b, // dominantB: stats.dominant.b,
}, // },
}); // });
await prisma.imageVariant.createMany({ // await prisma.imageVariant.createMany({
data: [ // data: [
{ // {
s3Key: originalKey, // s3Key: originalKey,
type: "original", // type: "original",
height: metadata.height, // height: metadata.height,
width: metadata.width, // width: metadata.width,
fileExtension: metadata.format, // fileExtension: metadata.format,
mimeType: "image/" + metadata.format, // mimeType: "image/" + metadata.format,
sizeBytes: metadata.size, // sizeBytes: metadata.size,
imageId: image.id // imageId: image.id
}, // },
{ // {
s3Key: watermarkedKey, // s3Key: watermarkedKey,
type: "watermarked", // type: "watermarked",
height: watermarkedMetadata.height, // height: watermarkedMetadata.height,
width: watermarkedMetadata.width, // width: watermarkedMetadata.width,
fileExtension: watermarkedMetadata.format, // fileExtension: watermarkedMetadata.format,
mimeType: "image/" + watermarkedMetadata.format, // mimeType: "image/" + watermarkedMetadata.format,
sizeBytes: watermarkedMetadata.size, // sizeBytes: watermarkedMetadata.size,
imageId: image.id // imageId: image.id
}, // },
{ // {
s3Key: resizedKey, // s3Key: resizedKey,
type: "resized", // type: "resized",
height: resizedMetadata.height, // height: resizedMetadata.height,
width: resizedMetadata.width, // width: resizedMetadata.width,
fileExtension: resizedMetadata.format, // fileExtension: resizedMetadata.format,
mimeType: "image/" + resizedMetadata.format, // mimeType: "image/" + resizedMetadata.format,
sizeBytes: resizedMetadata.size, // sizeBytes: resizedMetadata.size,
imageId: image.id // imageId: image.id
}, // },
{ // {
s3Key: thumbnailKey, // s3Key: thumbnailKey,
type: "thumbnail", // type: "thumbnail",
height: thumbnailMetadata.height, // height: thumbnailMetadata.height,
width: thumbnailMetadata.width, // width: thumbnailMetadata.width,
fileExtension: thumbnailMetadata.format, // fileExtension: thumbnailMetadata.format,
mimeType: "image/" + thumbnailMetadata.format, // mimeType: "image/" + thumbnailMetadata.format,
sizeBytes: thumbnailMetadata.size, // sizeBytes: thumbnailMetadata.size,
imageId: image.id // imageId: image.id
} // }
], // ],
}); // });
await upsertPalettes(primaryTones, image.id, "primary"); // await upsertPalettes(primaryTones, image.id, "primary");
await upsertPalettes(secondaryTones, image.id, "secondary"); // await upsertPalettes(secondaryTones, image.id, "secondary");
await upsertPalettes(tertiaryTones, image.id, "tertiary"); // await upsertPalettes(tertiaryTones, image.id, "tertiary");
await upsertPalettes(neutralTones, image.id, "neutral"); // await upsertPalettes(neutralTones, image.id, "neutral");
await upsertPalettes(neutralVariantTones, image.id, "neutralVariant"); // await upsertPalettes(neutralVariantTones, image.id, "neutralVariant");
await upsertPalettes(errorTones, image.id, "error"); // await upsertPalettes(errorTones, image.id, "error");
for (const [type, hex] of Object.entries(vibrantHexes)) { // for (const [type, hex] of Object.entries(vibrantHexes)) {
if (!hex) continue; // if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); // const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
await prisma.imageColor.create({ // await prisma.imageColor.create({
data: { // data: {
type, // type,
hex, // hex,
red: r, // red: r,
green: g, // green: g,
blue: b, // blue: b,
imageId: image.id, // imageId: image.id,
}, // },
}); // });
} // }
for (const c of extracted) { // for (const c of extracted) {
await prisma.extractColor.create({ // await prisma.extractColor.create({
data: { // data: {
hex: c.hex, // hex: c.hex,
red: c.red, // red: c.red,
green: c.green, // green: c.green,
blue: c.blue, // blue: c.blue,
hue: c.hue, // hue: c.hue,
saturation: c.saturation, // saturation: c.saturation,
// value: c.value, // // value: c.value,
area: c.area, // area: c.area,
// isLight: c.isLight, // // isLight: c.isLight,
imageId: image.id, // imageId: image.id,
}, // },
}); // });
} // }
await prisma.themeSeed.create({ // await prisma.themeSeed.create({
data: { // data: {
seedHex, // seedHex,
imageId: image.id, // imageId: image.id,
}, // },
}); // });
await prisma.pixelSummary.create({ // await prisma.pixelSummary.create({
data: { // data: {
width: pixels.shape[0], // width: pixels.shape[0],
height: pixels.shape[1], // height: pixels.shape[1],
channels: pixels.shape[2], // channels: pixels.shape[2],
imageId: image.id, // imageId: image.id,
}, // },
}); // });
return image // return image
// return await prisma.gallery.create({ // // return await prisma.gallery.create({
// data: { // // data: {
// name: values.name, // // name: values.name,
// slug: values.slug, // // slug: values.slug,
// description: values.description, // // description: values.description,
// } // // }
// }) // // })
} // }

View File

@ -2,6 +2,7 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
import { generateExtractColorName } from "@/utils/uploadHelper";
import { extractColors } from "extract-colors"; import { extractColors } from "extract-colors";
import getPixels from "get-pixels"; import getPixels from "get-pixels";
import { NdArray } from "ndarray"; import { NdArray } from "ndarray";
@ -34,21 +35,35 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
}); });
for (const c of extracted) { for (const c of extracted) {
await prisma.extractColor.create({ const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area);
await prisma.image.update({
where: { id: imageId },
data: { data: {
hex: c.hex, extractColors: {
red: c.red, connectOrCreate: {
green: c.green, where: { name },
blue: c.blue, create: {
hue: c.hue, name,
saturation: c.saturation, hex: c.hex,
area: c.area, red: c.red,
imageId: imageId, green: c.green,
blue: c.blue,
hue: c.hue,
saturation: c.saturation,
area: c.area,
},
},
},
}, },
}); });
} }
return await prisma.extractColor.findMany({ return await prisma.extractColor.findMany({
where: { imageId: imageId } where: {
images: {
some: { id: imageId },
},
},
}); });
} }

View File

@ -3,7 +3,7 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { VibrantSwatch } from "@/types/VibrantSwatch"; import { VibrantSwatch } from "@/types/VibrantSwatch";
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
import { rgbToHex } from "@/utils/uploadHelper"; import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
import { Vibrant } from "node-vibrant/node"; import { Vibrant } from "node-vibrant/node";
export async function generateImageColors(imageId: string, fileKey: string) { export async function generateImageColors(imageId: string, fileKey: string) {
@ -22,19 +22,33 @@ export async function generateImageColors(imageId: string, fileKey: string) {
for (const [type, hex] of Object.entries(vibrantHexes)) { for (const [type, hex] of Object.entries(vibrantHexes)) {
if (!hex) continue; if (!hex) continue;
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
await prisma.imageColor.create({ const name = generateColorName(hex);
await prisma.image.update({
where: { id: imageId },
data: { data: {
type, colors: {
hex, connectOrCreate: {
red: r, where: { name: name },
green: g, create: {
blue: b, name: name,
imageId: imageId, type: type,
hex: hex,
red: r,
green: g,
blue: b,
}
}
}
}, },
}); });
} }
return await prisma.imageColor.findMany({ return await prisma.imageColor.findMany({
where: { imageId: imageId } where: {
images: {
some: { id: imageId },
},
},
}); });
} }

View 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
}
})
}

View File

@ -0,0 +1,7 @@
"use server";
import prisma from "@/lib/prisma";
export async function deleteTag(id: string) {
await prisma.tag.delete({ where: { id } });
}

View 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,
}
})
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,3 +1,4 @@
import DeleteImageButton from "@/components/images/edit/DeleteImageButton";
import EditImageForm from "@/components/images/edit/EditImageForm"; import EditImageForm from "@/components/images/edit/EditImageForm";
import ExtractColors from "@/components/images/edit/ExtractColors"; import ExtractColors from "@/components/images/edit/ExtractColors";
import ImageColors from "@/components/images/edit/ImageColors"; import ImageColors from "@/components/images/edit/ImageColors";
@ -26,19 +27,26 @@ export default async function ImagesEditPage({ params }: { params: { id: string
include: { include: {
items: true items: true
} }
} },
tags: true,
categories: true
} }
}); });
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } }); const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } });
const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" } }); const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" } });
const tags = await prisma.tag.findMany({ orderBy: { createdAt: "asc" } });
const categories = await prisma.category.findMany({ orderBy: { createdAt: "asc" } });
return ( return (
<div> <div>
<h1 className="text-2xl font-bold mb-4">Edit image</h1> <h1 className="text-2xl font-bold mb-4">Edit image</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div> <div>
{image ? <EditImageForm image={image} artists={artists} albums={albums} /> : 'Image not found...'} {image ? <EditImageForm image={image} artists={artists} albums={albums} tags={tags} categories={categories} /> : 'Image not found...'}
<div className="mt-6">
{image && <DeleteImageButton imageId={image.id} />}
</div>
<div> <div>
{image && <ImageVariants variants={image.variants} />} {image && <ImageVariants variants={image.variants} />}
</div> </div>

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -27,6 +27,16 @@ export default function TopNav() {
<Link href="/artists">Artists</Link> <Link href="/artists">Artists</Link>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/categories">Categories</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/tags">Tags</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/images">Images</Link> <Link href="/images">Images</Link>

View 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>
);
}

View File

@ -5,10 +5,11 @@ import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Album, Artist, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, ThemeSeed } from "@/generated/prisma"; import { Album, Artist, Category, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, Tag, ThemeSeed } from "@/generated/prisma";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/images/imageSchema"; import { imageSchema } from "@/schemas/images/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -28,6 +29,8 @@ type ImageWithItems = Image & {
stats: ImageStats[], stats: ImageStats[],
theme: ThemeSeed[], theme: ThemeSeed[],
variants: ImageVariant[], variants: ImageVariant[],
tags: Tag[],
categories: Category[],
palettes: ( palettes: (
ColorPalette & { ColorPalette & {
items: ColorPaletteItem[] items: ColorPaletteItem[]
@ -35,7 +38,14 @@ type ImageWithItems = Image & {
)[] )[]
}; };
export default function EditImageForm({ image, artists, albums }: { image: ImageWithItems, artists: Artist[], albums: Album[] }) { export default function EditImageForm({ image, artists, albums, tags, categories }:
{
image: ImageWithItems,
artists: Artist[],
albums: Album[],
tags: Tag[],
categories: Category[]
}) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof imageSchema>>({ const form = useForm<z.infer<typeof imageSchema>>({
resolver: zodResolver(imageSchema), resolver: zodResolver(imageSchema),
@ -56,6 +66,8 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
artistId: image.artist?.id || undefined, artistId: image.artist?.id || undefined,
albumId: image.album?.id || undefined, albumId: image.album?.id || undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
categoryIds: image.categories?.map(cat => cat.id) ?? [],
}, },
}) })
@ -344,12 +356,76 @@ export default function EditImageForm({ image, artists, albums }: { image: Image
)} )}
/> />
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedOptions = tags
.filter(tag => field.value?.includes(tag.id))
.map(tag => ({ label: tag.name, value: tag.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={tags.map(tag => ({
label: tag.name,
value: tag.id,
}))}
placeholder="Select tags"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<FormField
control={form.control}
name="categoryIds"
render={({ field }) => {
const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id }));
return (
<FormItem>
<FormLabel>Categories</FormLabel>
<FormControl>
<MultipleSelector
defaultOptions={categories.map(cat => ({
label: cat.name,
value: cat.id,
}))}
placeholder="Select categories"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => {
const ids = options.map(option => option.value);
field.onChange(ids);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)
}}
/>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
</div> </div>
</form> </form>
</Form> </Form>
</div> </div >
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 }

View 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,
}

View 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,
}

View 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;

View 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(),
})

View File

@ -27,4 +27,6 @@ export const imageSchema = z.object({
artistId: z.string().optional(), artistId: z.string().optional(),
albumId: z.string().optional(), albumId: z.string().optional(),
tagIds: z.array(z.string()).optional(),
categoryIds: z.array(z.string()).optional(),
}) })

View 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(),
})

View File

@ -9,6 +9,17 @@ export function generatePaletteName(tones: Tone[]): string {
return `palette-${hash.slice(0, 8)}`; return `palette-${hash.slice(0, 8)}`;
} }
export function generateColorName(hex: string): string {
const hash = crypto.createHash("sha256").update(hex.toLowerCase()).digest("hex");
return `color-${hash.slice(0, 8)}`;
}
export function generateExtractColorName(hex: string, hue?: number, sat?: number, area?: number): string {
const data = `${hex.toLowerCase()}-${hue ?? 0}-${sat ?? 0}-${area ?? 0}`;
const hash = crypto.createHash("sha256").update(data).digest("hex");
return `extract-${hash.slice(0, 8)}`;
}
export function rgbToHex(rgb: number[]): string { export function rgbToHex(rgb: number[]): string {
return `#${rgb return `#${rgb
.map((val) => Math.round(val).toString(16).padStart(2, "0")) .map((val) => Math.round(val).toString(16).padStart(2, "0"))