Refactor colors and palettes

This commit is contained in:
2025-06-28 16:41:01 +02:00
parent 2a2cde2f02
commit 50617e5578
34 changed files with 872 additions and 243 deletions

View File

@ -0,0 +1,172 @@
/*
Warnings:
- You are about to drop the column `type` on the `ColorPalette` table. All the data in the column will be lost.
- You are about to drop the column `blue` on the `ImageColor` table. All the data in the column will be lost.
- You are about to drop the column `green` on the `ImageColor` table. All the data in the column will be lost.
- You are about to drop the column `hex` on the `ImageColor` table. All the data in the column will be lost.
- You are about to drop the column `name` on the `ImageColor` table. All the data in the column will be lost.
- You are about to drop the column `red` on the `ImageColor` table. All the data in the column will be lost.
- You are about to drop the `PixelSummary` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `ThemeSeed` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_ImagePalettes` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_ImageToExtractColor` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_ImageToImageColor` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[galleryId,slug]` on the table `Album` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[imageId,type]` on the table `ImageColor` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[imageId]` on the table `ImageMetadata` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[imageId]` on the table `ImageStats` will be added. If there are existing duplicate values, this will fail.
- Added the required column `colorId` to the `ImageColor` table without a default value. This is not possible if the table is not empty.
- Added the required column `imageId` to the `ImageColor` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "PixelSummary" DROP CONSTRAINT "PixelSummary_imageId_fkey";
-- DropForeignKey
ALTER TABLE "ThemeSeed" DROP CONSTRAINT "ThemeSeed_imageId_fkey";
-- DropForeignKey
ALTER TABLE "_ImagePalettes" DROP CONSTRAINT "_ImagePalettes_A_fkey";
-- DropForeignKey
ALTER TABLE "_ImagePalettes" DROP CONSTRAINT "_ImagePalettes_B_fkey";
-- DropForeignKey
ALTER TABLE "_ImageToExtractColor" DROP CONSTRAINT "_ImageToExtractColor_A_fkey";
-- DropForeignKey
ALTER TABLE "_ImageToExtractColor" DROP CONSTRAINT "_ImageToExtractColor_B_fkey";
-- DropForeignKey
ALTER TABLE "_ImageToImageColor" DROP CONSTRAINT "_ImageToImageColor_A_fkey";
-- DropForeignKey
ALTER TABLE "_ImageToImageColor" DROP CONSTRAINT "_ImageToImageColor_B_fkey";
-- DropIndex
DROP INDEX "ImageColor_name_key";
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "coverImageId" TEXT;
-- AlterTable
ALTER TABLE "ColorPalette" DROP COLUMN "type";
-- AlterTable
ALTER TABLE "Gallery" ADD COLUMN "coverImageId" TEXT;
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "source" TEXT;
-- AlterTable
ALTER TABLE "ImageColor" DROP COLUMN "blue",
DROP COLUMN "green",
DROP COLUMN "hex",
DROP COLUMN "name",
DROP COLUMN "red",
ADD COLUMN "colorId" TEXT NOT NULL,
ADD COLUMN "imageId" TEXT NOT NULL;
-- DropTable
DROP TABLE "PixelSummary";
-- DropTable
DROP TABLE "ThemeSeed";
-- DropTable
DROP TABLE "_ImagePalettes";
-- DropTable
DROP TABLE "_ImageToExtractColor";
-- DropTable
DROP TABLE "_ImageToImageColor";
-- CreateTable
CREATE TABLE "Color" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"hex" TEXT,
"blue" INTEGER,
"green" INTEGER,
"red" INTEGER,
CONSTRAINT "Color_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImagePalette" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"imageId" TEXT NOT NULL,
"paletteId" TEXT NOT NULL,
"type" TEXT NOT NULL,
CONSTRAINT "ImagePalette_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImageExtractColor" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"imageId" TEXT NOT NULL,
"extractId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"colorId" TEXT,
CONSTRAINT "ImageExtractColor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Color_name_key" ON "Color"("name");
-- CreateIndex
CREATE UNIQUE INDEX "ImagePalette_imageId_type_key" ON "ImagePalette"("imageId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "ImageExtractColor_imageId_type_key" ON "ImageExtractColor"("imageId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "Album_galleryId_slug_key" ON "Album"("galleryId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "ImageColor_imageId_type_key" ON "ImageColor"("imageId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "ImageMetadata_imageId_key" ON "ImageMetadata"("imageId");
-- CreateIndex
CREATE UNIQUE INDEX "ImageStats_imageId_key" ON "ImageStats"("imageId");
-- AddForeignKey
ALTER TABLE "Gallery" ADD CONSTRAINT "Gallery_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Image"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Album" ADD CONSTRAINT "Album_coverImageId_fkey" FOREIGN KEY ("coverImageId") REFERENCES "Image"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImagePalette" ADD CONSTRAINT "ImagePalette_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImagePalette" ADD CONSTRAINT "ImagePalette_paletteId_fkey" FOREIGN KEY ("paletteId") REFERENCES "ColorPalette"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageExtractColor" ADD CONSTRAINT "ImageExtractColor_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageExtractColor" ADD CONSTRAINT "ImageExtractColor_extractId_fkey" FOREIGN KEY ("extractId") REFERENCES "ExtractColor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageExtractColor" ADD CONSTRAINT "ImageExtractColor_colorId_fkey" FOREIGN KEY ("colorId") REFERENCES "Color"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_colorId_fkey" FOREIGN KEY ("colorId") REFERENCES "Color"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `colorId` on the `ImageExtractColor` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "ImageExtractColor" DROP CONSTRAINT "ImageExtractColor_colorId_fkey";
-- AlterTable
ALTER TABLE "ImageExtractColor" DROP COLUMN "colorId";

View File

@ -24,8 +24,8 @@ model Gallery {
description String? description String?
// coverImageId String? coverImageId String?
// coverImage Image? @relation("GalleryCoverImage", fields: [coverImageId], references: [id]) coverImage Image? @relation("GalleryCoverImage", fields: [coverImageId], references: [id])
albums Album[] albums Album[]
} }
@ -35,17 +35,19 @@ model Album {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
name String
slug String slug String
name String
description String? description String?
// coverImageId String? coverImageId String?
galleryId String? galleryId String?
// coverImage Image? @relation("AlbumCoverImage", fields: [coverImageId], references: [id]) coverImage Image? @relation("AlbumCoverImage", fields: [coverImageId], references: [id])
gallery Gallery? @relation(fields: [galleryId], references: [id]) gallery Gallery? @relation(fields: [galleryId], references: [id])
images Image[] images Image[]
@@unique([galleryId, slug])
} }
model Artist { model Artist {
@ -77,6 +79,30 @@ model Social {
artist Artist? @relation(fields: [artistId], references: [id]) artist Artist? @relation(fields: [artistId], 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")
}
model Image { model Image {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -91,6 +117,7 @@ model Image {
description String? description String?
fileType String? fileType String?
imageData String? imageData String?
source String?
creationMonth Int? creationMonth Int?
creationYear Int? creationYear Int?
fileSize Int? fileSize Int?
@ -100,22 +127,22 @@ model Image {
artistId String? artistId String?
album Album? @relation(fields: [albumId], references: [id]) album Album? @relation(fields: [albumId], references: [id])
artist Artist? @relation(fields: [artistId], references: [id]) artist Artist? @relation(fields: [artistId], references: [id])
// sourceId String?
// source Source? @relation(fields: [sourceId], references: [id])
metadata ImageMetadata[] metadata ImageMetadata?
pixels PixelSummary[] stats ImageStats?
stats ImageStats[]
theme ThemeSeed[]
variants ImageVariant[]
// albumCover Album[] @relation("AlbumCoverImage") colors ImageColor[]
// galleryCover Gallery[] @relation("GalleryCoverImage") extractColors ImageExtractColor[]
categories Category[] @relation("ImageCategories") palettes ImagePalette[]
colors ImageColor[] @relation("ImageToImageColor") variants ImageVariant[]
extractColors ExtractColor[] @relation("ImageToExtractColor") // pixels PixelSummary[]
palettes ColorPalette[] @relation("ImagePalettes") // theme ThemeSeed[]
tags Tag[] @relation("ImageTags") albumCover Album[] @relation("AlbumCoverImage")
galleryCover Gallery[] @relation("GalleryCoverImage")
categories Category[] @relation("ImageCategories")
// colors ImageColor[] @relation("ImageToImageColor")
tags Tag[] @relation("ImageTags")
// palettes ColorPalette[] @relation("ImagePalettes")
} }
model ImageMetadata { model ImageMetadata {
@ -123,7 +150,7 @@ model ImageMetadata {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
imageId String imageId String @unique
depth String depth String
format String format String
space String space String
@ -148,7 +175,7 @@ model ImageStats {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
imageId String imageId String @unique
entropy Float entropy Float
sharpness Float sharpness Float
dominantB Int dominantB Int
@ -184,10 +211,10 @@ model ColorPalette {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
name String name String
type String
items ColorPaletteItem[] items ColorPaletteItem[]
images Image[] @relation("ImagePalettes") images ImagePalette[]
// images Image[] @relation("ImagePalettes")
} }
model ColorPaletteItem { model ColorPaletteItem {
@ -217,10 +244,11 @@ model ExtractColor {
hue Float? hue Float?
saturation Float? saturation Float?
images Image[] @relation("ImageToExtractColor") // images Image[] @relation("ImageToExtractColor")
images ImageExtractColor[]
} }
model ImageColor { model Color {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -233,53 +261,74 @@ model ImageColor {
green Int? green Int?
red Int? red Int?
images Image[] @relation("ImageToImageColor") images ImageColor[]
} }
model ThemeSeed { // model ThemeSeed {
// id String @id @default(cuid())
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// imageId String
// seedHex String
// image Image @relation(fields: [imageId], references: [id])
// }
// model PixelSummary {
// id String @id @default(cuid())
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// imageId String
// channels Int
// height Int
// width Int
// image Image @relation(fields: [imageId], references: [id])
// }
model ImagePalette {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String
paletteId String
type String
image Image @relation(fields: [imageId], references: [id])
palette ColorPalette @relation(fields: [paletteId], references: [id])
@@unique([imageId, type])
}
model ImageExtractColor {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String
extractId String
type String
image Image @relation(fields: [imageId], references: [id])
extract ExtractColor @relation(fields: [extractId], references: [id])
@@unique([imageId, type])
}
model ImageColor {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
imageId String imageId String
seedHex String colorId String
type String
image Image @relation(fields: [imageId], references: [id]) image Image @relation(fields: [imageId], references: [id])
} color Color @relation(fields: [colorId], references: [id])
model PixelSummary { @@unique([imageId, type])
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
imageId String
channels Int
height Int
width Int
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

@ -16,7 +16,8 @@ export async function updateAlbum(
name: values.name, name: values.name,
slug: values.slug, slug: values.slug,
description: values.description, description: values.description,
galleryId: values.galleryId galleryId: values.galleryId,
coverImageId: values.coverImageId
} }
}) })
} }

View File

@ -16,6 +16,7 @@ export async function updateGallery(
name: values.name, name: values.name,
slug: values.slug, slug: values.slug,
description: values.description, description: values.description,
coverImageId: values.coverImageId
} }
}) })
} }

View File

@ -16,15 +16,16 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
metadata: true metadata: true
} }
}) })
const buffer = await getImageBufferFromS3(fileKey);
if(!image) throw new Error("Image not found"); if(!image) throw new Error("Image not found");
const buffer = await getImageBufferFromS3(fileKey);
const format = image.metadata?.format || "jpeg";
const imageDataUrl = `data:${image.fileType};base64,${buffer.toString("base64")}`; const imageDataUrl = `data:${image.fileType};base64,${buffer.toString("base64")}`;
const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => { const pixels = await new Promise<NdArray<Uint8Array>>((resolve, reject) => {
getPixels(imageDataUrl, 'image/' + image.metadata[0].format || "image/jpeg", (err, pixels) => { getPixels(imageDataUrl, `image/${format}`, (err, result) => {
if (err) reject(err); if (err) reject(err);
else resolve(pixels); else resolve(result);
}); });
}); });
@ -34,36 +35,46 @@ export async function generateExtractColors(imageId: string, fileKey: string) {
height: pixels.shape[1] height: pixels.shape[1]
}); });
let typeIndex = 0;
for (const c of extracted) { for (const c of extracted) {
const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area); const name = generateExtractColorName(c.hex, c.hue, c.saturation, c.area);
await prisma.image.update({ const extract = await prisma.extractColor.upsert({
where: { id: imageId }, where: { name },
data: { create: {
extractColors: { name,
connectOrCreate: { hex: c.hex,
where: { name }, red: c.red,
create: { green: c.green,
name, blue: c.blue,
hex: c.hex, hue: c.hue,
red: c.red, saturation: c.saturation,
green: c.green, area: c.area,
blue: c.blue, },
hue: c.hue, update: {},
saturation: c.saturation, });
area: c.area,
}, await prisma.imageExtractColor.upsert({
}, where: {
imageId_type: {
imageId,
type: `color-${typeIndex}`,
}, },
}, },
create: {
imageId,
extractId: extract.id,
type: `color-${typeIndex}`,
},
update: {},
}); });
typeIndex++;
} }
return await prisma.extractColor.findMany({ return await prisma.imageExtractColor.findMany({
where: { where: { imageId },
images: { include: { extract: true },
some: { id: imageId },
},
},
}); });
} }

View File

@ -10,45 +10,57 @@ export async function generateImageColors(imageId: string, fileKey: string) {
const buffer = await getImageBufferFromS3(fileKey); const buffer = await getImageBufferFromS3(fileKey);
const palette = await Vibrant.from(buffer).getPalette(); const palette = await Vibrant.from(buffer).getPalette();
const vibrantHexes = Object.fromEntries( const vibrantHexes = 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 { type: key, hex };
return [key, hex]; });
})
);
for (const [type, hex] of Object.entries(vibrantHexes)) { for (const { type, hex } of 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));
const name = generateColorName(hex); const name = generateColorName(hex);
await prisma.image.update({ const color = await prisma.color.upsert({
where: { id: imageId }, where: { name },
data: { create: {
colors: { name,
connectOrCreate: { type,
where: { name: name }, hex,
create: { red: r,
name: name, green: g,
type: type, blue: b,
hex: hex, },
red: r, update: {
green: g, hex,
blue: b, red: r,
} green: g,
} blue: b,
} },
});
await prisma.imageColor.upsert({
where: {
imageId_type: {
imageId,
type,
},
},
create: {
imageId,
colorId: color.id,
type,
},
update: {
colorId: color.id,
}, },
}); });
} }
return await prisma.imageColor.findMany({ return await prisma.imageColor.findMany({
where: { where: { imageId },
images: { include: { color: true },
some: { id: imageId },
},
},
}); });
} }

View File

@ -44,15 +44,16 @@ export async function generatePaletteAction(imageId: string, fileKey: string) {
await upsertPalettes(neutralVariantTones, imageId, "neutralVariant"); await upsertPalettes(neutralVariantTones, imageId, "neutralVariant");
await upsertPalettes(errorTones, imageId, "error"); await upsertPalettes(errorTones, imageId, "error");
await prisma.themeSeed.create({ return await prisma.imagePalette.findMany({
data: { where: {
seedHex, imageId: imageId
imageId,
}, },
}); include: {
palette: {
return await prisma.colorPalette.findMany({ include: {
where: { images: { some: { id: imageId } } }, items: true
include: { items: true }, }
}
}
}); });
} }

View File

@ -7,6 +7,10 @@ export default async function AlbumsEditPage({ params }: { params: { id: string
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
where: { where: {
id, id,
},
include: {
coverImage: true,
images: true
} }
}); });

View File

@ -7,7 +7,7 @@ export default async function AlbumsPage() {
const albums = await prisma.album.findMany( const albums = await prisma.album.findMany(
{ {
include: { gallery: true, images: { select: { id: true } } }, include: { gallery: true, images: { select: { id: true } } },
orderBy: { createdAt: "asc" } orderBy: { name: "asc" }
} }
); );

View File

@ -4,7 +4,10 @@ import { PlusCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export default async function ArtistsPage() { export default async function ArtistsPage() {
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } }); const artists = await prisma.artist.findMany({
orderBy: { createdAt: "asc" },
include: { images: { select: { id: true } } }
});
return ( return (
<div> <div>

View File

@ -6,7 +6,8 @@ import Link from "next/link";
export default async function CategoriesPage() { export default async function CategoriesPage() {
const categories = await prisma.category.findMany( const categories = await prisma.category.findMany(
{ {
orderBy: { createdAt: "asc" } orderBy: { createdAt: "asc" },
include: { images: { select: { id: true } } }
} }
); );

View File

@ -9,7 +9,8 @@ export default async function GalleriesEditPage({ params }: { params: { id: stri
id, id,
}, },
include: { include: {
albums: true albums: { include: { images: true } },
coverImage: true
} }
}); });

View File

@ -4,7 +4,12 @@ import { PlusCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export default async function GalleriesPage() { export default async function GalleriesPage() {
const galleries = await prisma.gallery.findMany({ orderBy: { createdAt: "asc" } }); const galleries = await prisma.gallery.findMany({
orderBy: { createdAt: "asc" },
include: {
albums: { select: { id: true } }
}
});
return ( return (
<div> <div>

View File

@ -16,27 +16,39 @@ export default async function ImagesEditPage({ params }: { params: { id: string
include: { include: {
album: true, album: true,
artist: true, artist: true,
colors: true,
extractColors: true,
metadata: true, metadata: true,
pixels: true,
stats: true, stats: true,
theme: true, colors: {
variants: true,
palettes: {
include: { include: {
items: true color: true
} }
}, },
extractColors: {
include: {
extract: true
}
},
palettes: {
include: {
palette: {
include: {
items: true
}
}
}
},
variants: true,
categories: true,
tags: true, tags: true,
categories: true
} }
}); });
const artists = await prisma.artist.findMany({ orderBy: { createdAt: "asc" } }); const albums = await prisma.album.findMany({ orderBy: { name: "asc" }, include: { gallery: { select: { name: true } } } });
const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" }, include: { gallery: true } }); const artists = await prisma.artist.findMany({ orderBy: { displayName: "asc" } });
const tags = await prisma.tag.findMany({ orderBy: { createdAt: "asc" } }); const categories = await prisma.category.findMany({ orderBy: { name: "asc" } });
const categories = await prisma.category.findMany({ orderBy: { createdAt: "asc" } }); const tags = await prisma.tag.findMany({ orderBy: { name: "asc" } });
console.log(image)
return ( return (
<div> <div>

View File

@ -0,0 +1,31 @@
import DisplayPalette from "@/components/palettes/single/DisplayPalette";
import prisma from "@/lib/prisma";
export default async function PalettesPage({ params }: { params: { id: string } }) {
const { id } = await params;
const palette = await prisma.colorPalette.findUnique({
where: {
id,
},
include: {
items: true,
images: {
include: {
image: {
include: {
variants: { where: { type: "thumbnail" } }
}
}
}
}
}
});
return (
<div>
<h1 className="text-2xl font-bold mb-4">Show palette</h1>
{palette ? <DisplayPalette palette={palette} /> : 'Palette not found...'}
</div>
);
}

23
src/app/palettes/page.tsx Normal file
View File

@ -0,0 +1,23 @@
import ListPalettes from "@/components/palettes/list/ListPalettes";
import prisma from "@/lib/prisma";
export default async function PalettesPage() {
const palettes = await prisma.colorPalette.findMany(
{
orderBy: { name: "asc" },
include: {
items: true,
images: { select: { id: true } }
}
}
);
return (
<div>
<div className="flex gap-4 justify-between">
<h1 className="text-2xl font-bold mb-4">Palettes</h1>
</div>
{palettes.length > 0 ? <ListPalettes palettes={palettes} /> : <p className="text-muted-foreground italic">No palettes found.</p>}
</div>
);
}

View File

@ -5,15 +5,21 @@ import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Album, Gallery } from "@/generated/prisma"; import { Album, Gallery, Image } from "@/generated/prisma";
import { albumSchema } from "@/schemas/albums/albumSchema"; import { albumSchema } from "@/schemas/albums/albumSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import NextImage from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import * as z from "zod/v4";
export default function EditAlbumForm({ album, galleries }: { album: Album, galleries: Gallery[] }) { type AlbumWithItems = Album & {
images: Image[],
coverImage: Image | null
}
export default function EditAlbumForm({ album, galleries }: { album: AlbumWithItems, galleries: Gallery[] }) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof albumSchema>>({ const form = useForm<z.infer<typeof albumSchema>>({
resolver: zodResolver(albumSchema), resolver: zodResolver(albumSchema),
@ -22,6 +28,7 @@ export default function EditAlbumForm({ album, galleries }: { album: Album, gall
slug: album.slug, slug: album.slug,
description: album.description || "", description: album.description || "",
galleryId: album.galleryId || "", galleryId: album.galleryId || "",
coverImageId: album.coverImage?.id || "",
}, },
}) })
@ -33,6 +40,8 @@ export default function EditAlbumForm({ album, galleries }: { album: Album, gall
} }
} }
const selectedImage = album.images.find(img => img.id === form.watch("coverImageId"));
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Form {...form}> <Form {...form}>
@ -109,6 +118,45 @@ export default function EditAlbumForm({ album, galleries }: { album: Album, gall
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="coverImageId"
render={({ field }) => (
<FormItem>
<FormLabel>Cover Image</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a cover image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{album.images.map((image) => (
<SelectItem key={image.id} value={image.id}>
{image.imageName}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Optional cover image shown in album previews.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{selectedImage?.fileKey && (
<div className="mt-2">
<p className="text-sm text-muted-foreground mb-1">Cover preview:</p>
<NextImage
src={`/api/image/thumbnails/${selectedImage.fileKey}.webp`}
width={128}
height={128}
alt="Selected cover"
className="w-32 h-auto rounded border"
/>
</div>
)}
<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>

View File

@ -2,7 +2,11 @@ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/componen
import { Artist } from "@/generated/prisma"; import { Artist } from "@/generated/prisma";
import Link from "next/link"; import Link from "next/link";
export default function ListArtists({ artists }: { artists: Artist[] }) { type ArtistsWithItems = Artist & {
images: { id: string }[]
}
export default function ListArtists({ artists }: { artists: ArtistsWithItems[] }) {
return ( return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{artists.map((artist) => ( {artists.map((artist) => (
@ -14,6 +18,7 @@ export default function ListArtists({ artists }: { artists: Artist[] }) {
<CardContent> <CardContent>
</CardContent> </CardContent>
<CardFooter> <CardFooter>
Connected to {artist.images.length} image{artist.images.length !== 1 ? "s" : ""}
</CardFooter> </CardFooter>
</Card> </Card>
</Link> </Link>

View File

@ -2,7 +2,11 @@ import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/componen
import { Category } from "@/generated/prisma"; import { Category } from "@/generated/prisma";
import Link from "next/link"; import Link from "next/link";
export default function ListCategories({ categories }: { categories: Category[] }) { type CategoriesWithItems = Category & {
images: { id: string }[]
}
export default function ListCategories({ categories }: { categories: CategoriesWithItems[] }) {
return ( return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{categories.map((cat) => ( {categories.map((cat) => (
@ -15,6 +19,7 @@ export default function ListCategories({ categories }: { categories: Category[]
{cat.description && <p className="text-sm text-muted-foreground">{cat.description}</p>} {cat.description && <p className="text-sm text-muted-foreground">{cat.description}</p>}
</CardContent> </CardContent>
<CardFooter> <CardFooter>
Connected to {cat.images.length} image{cat.images.length !== 1 ? "s" : ""}
</CardFooter> </CardFooter>
</Card> </Card>
</Link> </Link>

View File

@ -5,15 +5,26 @@ import { updateGallery } from "@/actions/galleries/updateGallery";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Album, Gallery } from "@/generated/prisma"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Album, Gallery, Image } from "@/generated/prisma";
import { gallerySchema } from "@/schemas/galleries/gallerySchema"; import { gallerySchema } from "@/schemas/galleries/gallerySchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import NextImage from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import * as z from "zod/v4";
export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albums: Album[] } }) { type GalleryWithItems = Gallery & {
albums: (Album &
{
images: Image[]
}
)[];
coverImage: Image | null
}
export default function EditGalleryForm({ gallery }: { gallery: GalleryWithItems }) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof gallerySchema>>({ const form = useForm<z.infer<typeof gallerySchema>>({
resolver: zodResolver(gallerySchema), resolver: zodResolver(gallerySchema),
@ -21,6 +32,7 @@ export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albu
name: gallery.name, name: gallery.name,
slug: gallery.slug, slug: gallery.slug,
description: gallery.description || "", description: gallery.description || "",
coverImageId: gallery.coverImage?.id || "",
}, },
}) })
@ -32,6 +44,9 @@ export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albu
} }
} }
const allGalleryImages = gallery.albums?.flatMap(a => a.images) || [];
const selectedImage = allGalleryImages.find(img => img.id === form.watch("coverImageId"));
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<Form {...form}> <Form {...form}>
@ -84,6 +99,44 @@ export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albu
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="coverImageId" // or whatever you store it as
render={({ field }) => (
<FormItem>
<FormLabel>Gallery Cover Image</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select cover image for gallery" />
</SelectTrigger>
</FormControl>
<SelectContent>
{allGalleryImages.map((img) => (
<SelectItem key={img.id} value={img.id}>
<div className="flex items-center gap-2">
<span className="truncate">{img.imageName}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{selectedImage?.fileKey && (
<div className="mt-2">
<p className="text-sm text-muted-foreground mb-1">Cover preview:</p>
<NextImage
src={`/api/image/thumbnails/${selectedImage.fileKey}.webp`}
width={128}
height={128}
alt="Selected cover"
className="w-32 h-auto rounded border"
/>
</div>
)}
<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>
@ -104,7 +157,7 @@ export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albu
</div> </div>
<div className="text-sm text-right"> <div className="text-sm text-right">
{/* Replace this with actual image count later */} {/* Replace this with actual image count later */}
<span className="font-mono">Images: 0</span> <span className="font-mono">Images: {album.images.length}</span>
</div> </div>
</li> </li>
))} ))}
@ -112,7 +165,7 @@ export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albu
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Total images in this gallery: <span className="font-semibold">0</span> Total images in this gallery: <span className="font-semibold">{allGalleryImages.length}</span>
</p> </p>
<div> <div>
{gallery.albums.length === 0 ? ( {gallery.albums.length === 0 ? (

View File

@ -1,10 +1,14 @@
// "use client" // "use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Gallery } from "@/generated/prisma"; import { Gallery } from "@/generated/prisma";
import Link from "next/link"; import Link from "next/link";
export default function ListGalleries({ galleries }: { galleries: Gallery[] }) { type GalleriesWithItems = Gallery & {
albums: { id: string }[]
}
export default function ListGalleries({ galleries }: { galleries: GalleriesWithItems[] }) {
return ( return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{galleries.map((gallery) => ( {galleries.map((gallery) => (
@ -16,6 +20,9 @@ export default function ListGalleries({ galleries }: { galleries: Gallery[] }) {
<CardContent> <CardContent>
{gallery.description && <p className="text-sm text-muted-foreground">{gallery.description}</p>} {gallery.description && <p className="text-sm text-muted-foreground">{gallery.description}</p>}
</CardContent> </CardContent>
<CardFooter>
Connected to {gallery.albums.length} album{gallery.albums.length !== 1 ? "s" : ""}
</CardFooter>
</Card> </Card>
</Link> </Link>
))} ))}

View File

@ -37,6 +37,16 @@ export default function TopNav() {
<Link href="/tags">Tags</Link> <Link href="/tags">Tags</Link>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/palettes">Palettes</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/colors">Colors</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,39 @@
"use client";
import { Image, ImagePalette, ImageVariant } from "@/generated/prisma";
import ImageComponent from "next/image";
import Link from "next/link";
type ImagePaletteWithItems = {
image: ImagePalette & {
image: Image & {
variants: ImageVariant[];
}
};
};
export default function ImageCard({ image }: ImagePaletteWithItems) {
const thumbnail = image.image.variants.find((v) => v.type === "thumbnail");
return (
<Link
href={`/images/${image.image.id}`}
className="block overflow-hidden rounded-md border shadow-sm hover:shadow-md transition-shadow"
>
{thumbnail?.s3Key ? (
<ImageComponent
src={`/api/image/${thumbnail.s3Key}`}
alt={image.image.altText || image.image.imageName}
width={thumbnail.width}
height={thumbnail.height}
className="w-full h-auto object-cover"
/>
) : (
<div className="w-full h-48 bg-gray-100 flex items-center justify-center text-muted-foreground text-sm">
No Thumbnail
</div>
)}
<div className="p-2 text-sm truncate">{image.image.imageName} ({image.type})</div>
</Link>
);
}

View File

@ -9,7 +9,7 @@ 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, Category, ColorPalette, ColorPaletteItem, ExtractColor, Gallery, Image, ImageColor, ImageMetadata, ImageStats, ImageVariant, PixelSummary, Tag, ThemeSeed } from "@/generated/prisma"; import { Album, Artist, Category, Color, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageExtractColor, ImageMetadata, ImagePalette, ImageStats, ImageVariant, Tag } 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";
@ -22,33 +22,43 @@ import * as z from "zod/v4";
type ImageWithItems = Image & { type ImageWithItems = Image & {
album: Album | null, album: Album | null,
artist: Artist | null, artist: Artist | null,
colors: ImageColor[], metadata: ImageMetadata | null,
extractColors: ExtractColor[], stats: ImageStats | null,
metadata: ImageMetadata[], colors: (
pixels: PixelSummary[], ImageColor & {
stats: ImageStats[], color: Color
theme: ThemeSeed[],
variants: ImageVariant[],
tags: Tag[],
categories: Category[],
palettes: (
ColorPalette & {
items: ColorPaletteItem[]
} }
)[] )[],
extractColors: (
ImageExtractColor & {
extract: ExtractColor
}
)[],
palettes: (
ImagePalette & {
palette: (
ColorPalette & {
items: ColorPaletteItem[]
}
)
}
)[],
variants: ImageVariant[],
categories: Category[],
tags: Tag[],
}; };
type AlbumsWithGallery = Album & { type AlbumsWithGallery = Album & {
gallery: Gallery | null gallery: { name: string } | null
} }
export default function EditImageForm({ image, artists, albums, tags, categories }: export default function EditImageForm({ image, albums, artists, categories, tags }:
{ {
image: ImageWithItems, image: ImageWithItems,
artists: Artist[],
albums: AlbumsWithGallery[], albums: AlbumsWithGallery[],
tags: Tag[], artists: Artist[],
categories: Category[] categories: Category[]
tags: Tag[],
}) { }) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof imageSchema>>({ const form = useForm<z.infer<typeof imageSchema>>({
@ -62,7 +72,7 @@ export default function EditImageForm({ image, artists, albums, tags, categories
altText: image.altText || "", altText: image.altText || "",
description: image.description || "", description: image.description || "",
fileType: image.fileType || "", fileType: image.fileType || "",
imageData: image.imageData || "", source: image.source || "",
creationMonth: image.creationMonth || undefined, creationMonth: image.creationMonth || undefined,
creationYear: image.creationYear || undefined, creationYear: image.creationYear || undefined,
fileSize: image.fileSize || undefined, fileSize: image.fileSize || undefined,
@ -192,10 +202,10 @@ export default function EditImageForm({ image, artists, albums, tags, categories
/> />
<FormField <FormField
control={form.control} control={form.control}
name="imageData" name="source"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>imageData</FormLabel> <FormLabel>source</FormLabel>
<FormControl><Input {...field} disabled /></FormControl> <FormControl><Input {...field} disabled /></FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -2,11 +2,15 @@
import { generateExtractColors } from "@/actions/images/generateExtractColors"; import { generateExtractColors } from "@/actions/images/generateExtractColors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ExtractColor } from "@/generated/prisma"; import { ExtractColor, ImageExtractColor } from "@/generated/prisma";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export default function ExtractColors({ colors: initialColors, imageId, fileKey }: { colors: ExtractColor[], imageId: string, fileKey: string }) { type ExtractWithJoin = ImageExtractColor & {
extract: ExtractColor;
};
export default function ExtractColors({ colors: initialColors, imageId, fileKey }: { colors: ExtractWithJoin[], imageId: string, fileKey: string }) {
const [colors, setColors] = useState(initialColors); const [colors, setColors] = useState(initialColors);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -32,8 +36,13 @@ export default function ExtractColors({ colors: initialColors, imageId, fileKey
</Button> </Button>
</div > </div >
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{colors.map((color, index) => ( {colors.map((item) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex }} title={color.hex}></div> <div
key={`${item.imageId}-${item.type}`}
className="w-10 h-10 rounded"
style={{ backgroundColor: item.extract?.hex ?? "#000000" }}
title={`${item.type} ${item.extract?.hex}`}
/>
))} ))}
</div> </div>
</> </>

View File

@ -2,11 +2,15 @@
import { generateImageColors } from "@/actions/images/generateImageColors"; import { generateImageColors } from "@/actions/images/generateImageColors";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ImageColor } from "@/generated/prisma"; import { Color, ImageColor } from "@/generated/prisma";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
export default function ImageColors({ colors: initialColors, imageId, fileKey }: { colors: ImageColor[], imageId: string, fileKey: string }) { type ColorWithItems = ImageColor & {
color: Color
};
export default function ImageColors({ colors: initialColors, imageId, fileKey }: { colors: ColorWithItems[], imageId: string, fileKey: string }) {
const [colors, setColors] = useState(initialColors); const [colors, setColors] = useState(initialColors);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@ -31,15 +35,14 @@ export default function ImageColors({ colors: initialColors, imageId, fileKey }:
{isPending ? "Extracting..." : "Generate Palette"} {isPending ? "Extracting..." : "Generate Palette"}
</Button> </Button>
</div > </div >
{/* <h2 className="font-semibold text-lg mb-2">Extracted Colors</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{extractColors.map((color, index) => ( {colors.map((item) => (
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex }} title={color.hex}></div> <div
))} key={`${item.imageId}-${item.type}`}
</div> */} className="w-10 h-10 rounded"
<div className="flex flex-wrap gap-2"> style={{ backgroundColor: item.color?.hex ?? "#000000" }}
{colors.map((color, index) => ( title={`${item.type} ${item.color?.hex}`}
<div key={index} className="w-10 h-10 rounded" style={{ backgroundColor: color.hex ?? "#000000" }} title={`Tone ${color.type} - ${color.hex}`}></div> ></div>
))} ))}
</div> </div>
</> </>

View File

@ -2,12 +2,14 @@
import { generatePaletteAction } from "@/actions/images/generatePalette"; import { generatePaletteAction } from "@/actions/images/generatePalette";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ColorPalette, ColorPaletteItem } from "@/generated/prisma"; import { ColorPalette, ColorPaletteItem, ImagePalette } from "@/generated/prisma";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
type PaletteWithItems = ColorPalette & { type PaletteWithItems = ImagePalette & {
items: ColorPaletteItem[]; palette: ColorPalette & {
items: ColorPaletteItem[]
};
}; };
export default function ImagePalettes({ palettes: initialPalettes, imageId, fileKey }: { palettes: PaletteWithItems[], imageId: string, fileKey: string }) { export default function ImagePalettes({ palettes: initialPalettes, imageId, fileKey }: { palettes: PaletteWithItems[], imageId: string, fileKey: string }) {
@ -37,12 +39,12 @@ export default function ImagePalettes({ palettes: initialPalettes, imageId, file
</div > </div >
<div className="space-y-4"> <div className="space-y-4">
{palettes.map((palette) => ( {palettes.map((imagePalette) => (
palette.type != 'error' ? imagePalette.type != 'error' ?
<div key={palette.id}> <div key={imagePalette.id}>
<div className="text-sm font-medium mb-1">{palette.type}</div> <div className="text-sm font-medium mb-1">{imagePalette.type}</div>
<div className="flex gap-1"> <div className="flex gap-1">
{palette.items {imagePalette.palette.items
.filter((item) => item.tone !== null && item.hex !== null) .filter((item) => item.tone !== null && item.hex !== null)
.sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0)) .sort((a, b) => (a.tone ?? 0) - (b.tone ?? 0))
.map((item) => ( .map((item) => (

View File

@ -0,0 +1,39 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { ColorPalette, ColorPaletteItem } from "@/generated/prisma";
import Link from "next/link";
type PalettesWithItems = ColorPalette & {
items: ColorPaletteItem[],
images: { id: string }[]
}
export default function ListPalettes({ palettes }: { palettes: PalettesWithItems[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{palettes.map((palette) => (
<Link href={`/palettes/${palette.id}`} key={palette.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{palette.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{palette.items.map((item) => (
<div
key={item.id}
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: item.hex ?? "#ccc" }}
title={item.hex ?? "n/a"}
/>
))}
</div>
</CardContent>
<CardFooter>
Used by {palette.images.length} image{palette.images.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,45 @@
import ImageCard from "@/components/images/ImageCard";
import { ColorPalette, ColorPaletteItem, Image, ImagePalette, ImageVariant } from "@/generated/prisma";
type PaletteWithItems = ColorPalette & {
items: ColorPaletteItem[],
images: (ImagePalette & {
image: Image & {
variants: ImageVariant[]
}
})[]
}
export default function DisplayPalette({ palette }: { palette: PaletteWithItems }) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{palette.name}</h1>
{/* <p className="text-muted-foreground">Type: {palette.type}</p> */}
</div>
<div className="flex flex-wrap gap-2">
{palette.items.map((item) => (
<div
key={item.id}
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: item.hex ?? "#ccc" }}
title={item.hex ?? "n/a"}
/>
))}
</div>
<div>
<h2 className="text-xl font-semibold">Used by {palette.images.length} image(s)</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{palette.images.map((image) => (
<ImageCard key={image.id} image={image} />
))}
</div>
</div>
</div>
);
}

View File

@ -5,5 +5,6 @@ export const albumSchema = z.object({
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"), slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
galleryId: z.string().min(1, "Please select a gallery"), galleryId: z.string().min(1, "Please select a gallery"),
description: z.string().optional(), description: z.string().optional(),
coverImageId: z.string().optional(),
}) })

View File

@ -4,4 +4,5 @@ export const gallerySchema = z.object({
name: z.string().min(3, "Name is required. Min 3 characters."), name: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"), slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
description: z.string().optional(), description: z.string().optional(),
coverImageId: z.string().optional(),
}) })

View File

@ -18,15 +18,15 @@ export const imageSchema = z.object({
altText: z.string().optional(), altText: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
fileType: z.string().optional(), fileType: z.string().optional(),
imageData: z.string().optional(), source: z.string().optional(),
creationMonth: z.number().min(1).max(12).optional(), creationMonth: z.number().min(1).max(12).optional(),
creationYear: z.number().min(1900).max(2100).optional(), creationYear: z.number().min(1900).max(2100).optional(),
fileSize: z.number().optional(), fileSize: z.number().optional(),
creationDate: z.date().optional(), creationDate: z.date().optional(),
artistId: z.string().optional(),
albumId: z.string().optional(), albumId: z.string().optional(),
tagIds: z.array(z.string()).optional(), artistId: z.string().optional(),
categoryIds: z.array(z.string()).optional(), categoryIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
}) })

View File

@ -44,42 +44,56 @@ export async function upsertPalettes(tones: Tone[], imageId: string, type: strin
const existingPalette = await prisma.colorPalette.findFirst({ const existingPalette = await prisma.colorPalette.findFirst({
where: { name: paletteName }, where: { name: paletteName },
include: { images: { select: { id: true } } },
});
if (existingPalette) {
const alreadyLinked = existingPalette.images.some(img => img.id === imageId);
if (!alreadyLinked) {
await prisma.colorPalette.update({
where: { id: existingPalette.id },
data: {
images: {
connect: { id: imageId },
},
},
});
}
return existingPalette;
}
const newPalette = await prisma.colorPalette.create({
data: {
name: paletteName,
type: type,
items: {
create: tones.map(t => ({
tone: t.tone,
hex: t.hex,
})),
},
images: {
connect: { id: imageId },
},
},
include: { items: true }, include: { items: true },
}); });
return newPalette; //
const palette = existingPalette ?? await prisma.colorPalette.create({
data: {
name: paletteName,
items: {
create: tones.map(tone => ({
tone: tone.tone,
hex: tone.hex,
}))
}
}
});
await prisma.imagePalette.upsert({
where: {
imageId_type: {
imageId,
type,
}
},
update: {
paletteId: palette.id
},
create: {
imageId,
paletteId: palette.id,
type,
}
});
// const newPalette = await prisma.colorPalette.create({
// data: {
// name: paletteName,
// type: type,
// items: {
// create: tones.map(t => ({
// tone: t.tone,
// hex: t.hex,
// })),
// },
// images: {
// connect: { id: imageId },
// },
// },
// include: { items: true },
// });
return palette;
} }