Add image handling
This commit is contained in:
2725
package-lock.json
generated
2725
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,11 +9,14 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.844.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@platejs/basic-nodes": "^49.0.0",
|
||||
"@platejs/code-block": "^49.0.0",
|
||||
"@platejs/indent": "^49.0.0",
|
||||
@ -40,20 +43,25 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-vibrant": "^4.0.3",
|
||||
"platejs": "^49.1.5",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.59.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^4.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.73"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
197
prisma/migrations/20250712182812_add_images/migration.sql
Normal file
197
prisma/migrations/20250712182812_add_images/migration.sql
Normal file
@ -0,0 +1,197 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "CommissionRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"customerEmail" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"optionId" TEXT,
|
||||
"typeId" TEXT,
|
||||
|
||||
CONSTRAINT "CommissionRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PortfolioImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"fileKey" TEXT NOT NULL,
|
||||
"originalFile" TEXT NOT NULL,
|
||||
"nsfw" BOOLEAN NOT NULL DEFAULT false,
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"altText" TEXT,
|
||||
"description" TEXT,
|
||||
"fileType" TEXT,
|
||||
"name" TEXT,
|
||||
"slug" TEXT,
|
||||
"type" TEXT,
|
||||
"creationDate" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "PortfolioImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PortfolioCategory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"name" TEXT,
|
||||
"slug" TEXT,
|
||||
|
||||
CONSTRAINT "PortfolioCategory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PortfolioTag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"sortIndex" INTEGER NOT NULL DEFAULT 0,
|
||||
"name" TEXT,
|
||||
"slug" TEXT,
|
||||
|
||||
CONSTRAINT "PortfolioTag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 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 "ImageColor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"imageId" TEXT NOT NULL,
|
||||
"colorId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ImageColor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImageMetadata" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"imageId" TEXT NOT NULL,
|
||||
"depth" TEXT NOT NULL,
|
||||
"format" TEXT NOT NULL,
|
||||
"space" TEXT NOT NULL,
|
||||
"channels" INTEGER NOT NULL,
|
||||
"height" INTEGER NOT NULL,
|
||||
"width" INTEGER NOT NULL,
|
||||
"autoOrientH" INTEGER,
|
||||
"autoOrientW" INTEGER,
|
||||
"bitsPerSample" INTEGER,
|
||||
"density" INTEGER,
|
||||
"hasAlpha" BOOLEAN,
|
||||
"hasProfile" BOOLEAN,
|
||||
"isPalette" BOOLEAN,
|
||||
"isProgressive" BOOLEAN,
|
||||
|
||||
CONSTRAINT "ImageMetadata_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImageVariant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"imageId" TEXT NOT NULL,
|
||||
"s3Key" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"height" INTEGER NOT NULL,
|
||||
"width" INTEGER NOT NULL,
|
||||
"fileExtension" TEXT,
|
||||
"mimeType" TEXT,
|
||||
"url" TEXT,
|
||||
"sizeBytes" INTEGER,
|
||||
|
||||
CONSTRAINT "ImageVariant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_PortfolioImageToPortfolioTag" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_PortfolioImageToPortfolioTag_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_PortfolioCategoryToPortfolioImage" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_PortfolioCategoryToPortfolioImage_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PortfolioImage_fileKey_key" ON "PortfolioImage"("fileKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PortfolioImage_originalFile_key" ON "PortfolioImage"("originalFile");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Color_name_key" ON "Color"("name");
|
||||
|
||||
-- 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 "ImageVariant_imageId_type_key" ON "ImageVariant"("imageId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_PortfolioImageToPortfolioTag_B_index" ON "_PortfolioImageToPortfolioTag"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_PortfolioCategoryToPortfolioImage_B_index" ON "_PortfolioCategoryToPortfolioImage"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageColor" ADD CONSTRAINT "ImageColor_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("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;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageMetadata" ADD CONSTRAINT "ImageMetadata_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImageVariant" ADD CONSTRAINT "ImageVariant_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PortfolioImageToPortfolioTag" ADD CONSTRAINT "_PortfolioImageToPortfolioTag_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_A_fkey" FOREIGN KEY ("A") REFERENCES "PortfolioCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_PortfolioCategoryToPortfolioImage" ADD CONSTRAINT "_PortfolioCategoryToPortfolioImage_B_fkey" FOREIGN KEY ("B") REFERENCES "PortfolioImage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PortfolioImage" ADD COLUMN "fileSize" INTEGER;
|
@ -27,6 +27,7 @@ model CommissionType {
|
||||
options CommissionTypeOption[]
|
||||
extras CommissionTypeExtra[]
|
||||
customInputs CommissionTypeCustomInput[]
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionOption {
|
||||
@ -39,7 +40,8 @@ model CommissionOption {
|
||||
|
||||
description String?
|
||||
|
||||
types CommissionTypeOption[]
|
||||
types CommissionTypeOption[]
|
||||
requests CommissionRequest[]
|
||||
}
|
||||
|
||||
model CommissionExtra {
|
||||
@ -132,3 +134,151 @@ model TermsOfService {
|
||||
version Int @default(autoincrement())
|
||||
markdown String
|
||||
}
|
||||
|
||||
model CommissionRequest {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
customerName String
|
||||
customerEmail String
|
||||
message String
|
||||
|
||||
optionId String?
|
||||
typeId String?
|
||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||
}
|
||||
|
||||
model PortfolioImage {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
fileKey String @unique
|
||||
originalFile String @unique
|
||||
nsfw Boolean @default(false)
|
||||
published Boolean @default(false)
|
||||
|
||||
altText String?
|
||||
description String?
|
||||
fileType String?
|
||||
name String?
|
||||
slug String?
|
||||
type String?
|
||||
fileSize Int?
|
||||
creationDate DateTime?
|
||||
|
||||
metadata ImageMetadata?
|
||||
|
||||
categories PortfolioCategory[]
|
||||
colors ImageColor[]
|
||||
tags PortfolioTag[]
|
||||
variants ImageVariant[]
|
||||
}
|
||||
|
||||
model PortfolioCategory {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String @unique
|
||||
|
||||
slug String?
|
||||
description String?
|
||||
|
||||
images PortfolioImage[]
|
||||
}
|
||||
|
||||
model PortfolioTag {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sortIndex Int @default(0)
|
||||
|
||||
name String @unique
|
||||
|
||||
slug String?
|
||||
description String?
|
||||
|
||||
images PortfolioImage[]
|
||||
}
|
||||
|
||||
model Color {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String @unique
|
||||
type String
|
||||
|
||||
hex String?
|
||||
blue Int?
|
||||
green Int?
|
||||
red Int?
|
||||
|
||||
images ImageColor[]
|
||||
}
|
||||
|
||||
model ImageColor {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String
|
||||
colorId String
|
||||
type String
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
color Color @relation(fields: [colorId], references: [id])
|
||||
|
||||
@@unique([imageId, type])
|
||||
}
|
||||
|
||||
model ImageMetadata {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String @unique
|
||||
depth String
|
||||
format String
|
||||
space String
|
||||
channels Int
|
||||
height Int
|
||||
width Int
|
||||
|
||||
autoOrientH Int?
|
||||
autoOrientW Int?
|
||||
bitsPerSample Int?
|
||||
density Int?
|
||||
hasAlpha Boolean?
|
||||
hasProfile Boolean?
|
||||
isPalette Boolean?
|
||||
isProgressive Boolean?
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
}
|
||||
|
||||
model ImageVariant {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
imageId String
|
||||
s3Key String
|
||||
type String
|
||||
height Int
|
||||
width Int
|
||||
|
||||
fileExtension String?
|
||||
mimeType String?
|
||||
url String?
|
||||
sizeBytes Int?
|
||||
|
||||
image PortfolioImage @relation(fields: [imageId], references: [id])
|
||||
|
||||
@@unique([imageId, type])
|
||||
}
|
||||
|
67
src/actions/portfolio/edit/deleteImage.ts
Normal file
67
src/actions/portfolio/edit/deleteImage.ts
Normal file
@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
export async function deleteImage(imageId: string) {
|
||||
const image = await prisma.portfolioImage.findUnique({
|
||||
where: { id: imageId },
|
||||
include: {
|
||||
variants: true,
|
||||
colors: true,
|
||||
metadata: true,
|
||||
tags: true,
|
||||
categories: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!image) throw new Error("Image not found");
|
||||
|
||||
// Delete S3 objects
|
||||
for (const variant of image.variants) {
|
||||
try {
|
||||
await s3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: variant.s3Key,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Failed to delete S3 object: " + variant.s3Key + ". " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Delete join entries
|
||||
await prisma.imageColor.deleteMany({ where: { imageId } });
|
||||
|
||||
// Colors
|
||||
for (const color of image.colors) {
|
||||
const count = await prisma.imageColor.count({
|
||||
where: { colorId: color.colorId },
|
||||
});
|
||||
if (count === 0) {
|
||||
await prisma.color.delete({ where: { id: color.colorId } });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete variants
|
||||
await prisma.imageVariant.deleteMany({ where: { imageId } });
|
||||
|
||||
// Delete metadata
|
||||
await prisma.imageMetadata.deleteMany({ where: { imageId } });
|
||||
|
||||
// Clean many-to-many tag/category joins
|
||||
await prisma.portfolioImage.update({
|
||||
where: { id: imageId },
|
||||
data: {
|
||||
tags: { set: [] },
|
||||
categories: { set: [] },
|
||||
},
|
||||
});
|
||||
|
||||
// Finally delete the image
|
||||
await prisma.portfolioImage.delete({ where: { id: imageId } });
|
||||
|
||||
return { success: true };
|
||||
}
|
66
src/actions/portfolio/edit/generateImageColors.ts
Normal file
66
src/actions/portfolio/edit/generateImageColors.ts
Normal file
@ -0,0 +1,66 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
|
||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
|
||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
|
||||
import { Vibrant } from "node-vibrant/node";
|
||||
|
||||
export async function generateImageColors(imageId: string, fileKey: string, fileType?: string) {
|
||||
const buffer = await getImageBufferFromS3(fileKey, fileType);
|
||||
const palette = await Vibrant.from(buffer).getPalette();
|
||||
|
||||
const vibrantHexes = Object.entries(palette).map(([key, swatch]) => {
|
||||
const castSwatch = swatch as VibrantSwatch | null;
|
||||
const rgb = castSwatch?._rgb;
|
||||
const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
|
||||
return { type: key, hex };
|
||||
});
|
||||
|
||||
for (const { type, hex } of vibrantHexes) {
|
||||
if (!hex) continue;
|
||||
|
||||
const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
|
||||
const name = generateColorName(hex);
|
||||
|
||||
const color = await prisma.color.upsert({
|
||||
where: { name },
|
||||
create: {
|
||||
name,
|
||||
type,
|
||||
hex,
|
||||
red: r,
|
||||
green: g,
|
||||
blue: b,
|
||||
},
|
||||
update: {
|
||||
hex,
|
||||
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({
|
||||
where: { imageId },
|
||||
include: { color: true },
|
||||
});
|
||||
}
|
74
src/actions/portfolio/edit/updateImage.ts
Normal file
74
src/actions/portfolio/edit/updateImage.ts
Normal file
@ -0,0 +1,74 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { imageSchema } from "@/schemas/imageSchema";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function updateImage(
|
||||
values: z.infer<typeof imageSchema>,
|
||||
id: string
|
||||
) {
|
||||
const validated = imageSchema.safeParse(values);
|
||||
if (!validated.success) {
|
||||
throw new Error("Invalid image data");
|
||||
}
|
||||
|
||||
const {
|
||||
fileKey,
|
||||
originalFile,
|
||||
nsfw,
|
||||
published,
|
||||
altText,
|
||||
description,
|
||||
fileType,
|
||||
name,
|
||||
slug,
|
||||
type,
|
||||
fileSize,
|
||||
creationDate,
|
||||
tagIds,
|
||||
categoryIds
|
||||
} = validated.data;
|
||||
|
||||
const updatedImage = await prisma.portfolioImage.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
fileKey,
|
||||
originalFile,
|
||||
nsfw,
|
||||
published,
|
||||
altText,
|
||||
description,
|
||||
fileType,
|
||||
name,
|
||||
slug,
|
||||
type,
|
||||
fileSize,
|
||||
creationDate,
|
||||
}
|
||||
});
|
||||
|
||||
if (tagIds) {
|
||||
await prisma.portfolioImage.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
tags: {
|
||||
set: tagIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (categoryIds) {
|
||||
await prisma.portfolioImage.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
categories: {
|
||||
set: categoryIds.map(id => ({ id }))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return updatedImage
|
||||
}
|
15
src/actions/portfolio/updateImageSortOrder.ts
Normal file
15
src/actions/portfolio/updateImageSortOrder.ts
Normal file
@ -0,0 +1,15 @@
|
||||
'use server';
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { SortableItem } from "@/types/SortableItem";
|
||||
|
||||
export async function updateImageSortOrder(items: SortableItem[]) {
|
||||
await Promise.all(
|
||||
items.map(item =>
|
||||
prisma.portfolioImage.update({
|
||||
where: { id: item.id },
|
||||
data: { sortIndex: item.sortIndex },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
190
src/actions/portfolio/upload/uploadImage.ts
Normal file
190
src/actions/portfolio/upload/uploadImage.ts
Normal file
@ -0,0 +1,190 @@
|
||||
"use server"
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { imageUploadSchema } from "@/schemas/imageSchema";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import sharp from "sharp";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
|
||||
const imageFile = values.file[0];
|
||||
|
||||
if (!(imageFile instanceof File)) {
|
||||
console.log("No image or invalid type");
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = imageFile.name;
|
||||
const fileType = imageFile.type;
|
||||
const fileSize = imageFile.size;
|
||||
const lastModified = new Date(imageFile.lastModified);
|
||||
|
||||
const fileKey = uuidv4();
|
||||
|
||||
const arrayBuffer = await imageFile.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const realFileType = fileType.split("/")[1];
|
||||
const originalKey = `original/${fileKey}.${realFileType}`;
|
||||
const modifiedKey = `modified/${fileKey}.webp`;
|
||||
const resizedKey = `resized/${fileKey}.webp`;
|
||||
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
||||
|
||||
const sharpData = sharp(buffer);
|
||||
const metadata = await sharpData.metadata();
|
||||
|
||||
//--- Original file
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: originalKey,
|
||||
Body: buffer,
|
||||
ContentType: "image/" + metadata.format,
|
||||
})
|
||||
);
|
||||
//--- Modified file
|
||||
const modifiedBuffer = await sharp(buffer)
|
||||
.toFormat('webp')
|
||||
.toBuffer()
|
||||
const modifiedMetadata = await sharp(modifiedBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: modifiedKey,
|
||||
Body: modifiedBuffer,
|
||||
ContentType: "image/" + modifiedMetadata.format,
|
||||
})
|
||||
);
|
||||
//--- Resized file
|
||||
const { width, height } = modifiedMetadata;
|
||||
const targetSize = 400;
|
||||
let resizeOptions;
|
||||
if (width && height) {
|
||||
if (height < width) {
|
||||
resizeOptions = { height: targetSize };
|
||||
} else {
|
||||
resizeOptions = { width: targetSize };
|
||||
}
|
||||
} else {
|
||||
resizeOptions = { height: targetSize };
|
||||
}
|
||||
const resizedBuffer = await sharp(modifiedBuffer)
|
||||
.resize({ ...resizeOptions, withoutEnlargement: true })
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
const resizedMetadata = await sharp(resizedBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: resizedKey,
|
||||
Body: resizedBuffer,
|
||||
ContentType: "image/" + resizedMetadata.format,
|
||||
})
|
||||
);
|
||||
//--- Thumbnail file
|
||||
// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
|
||||
const thumbnailTargetSize = 160;
|
||||
let thumbnailOptions;
|
||||
if (width && height) {
|
||||
if (height < width) {
|
||||
thumbnailOptions = { height: thumbnailTargetSize };
|
||||
} else {
|
||||
thumbnailOptions = { width: thumbnailTargetSize };
|
||||
}
|
||||
} else {
|
||||
thumbnailOptions = { height: thumbnailTargetSize };
|
||||
}
|
||||
const thumbnailBuffer = await sharp(modifiedBuffer)
|
||||
.resize({ ...thumbnailOptions, withoutEnlargement: true })
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: thumbnailKey,
|
||||
Body: thumbnailBuffer,
|
||||
ContentType: "image/" + thumbnailMetadata.format,
|
||||
})
|
||||
);
|
||||
|
||||
const image = await prisma.portfolioImage.create({
|
||||
data: {
|
||||
name: fileName,
|
||||
fileKey,
|
||||
originalFile: fileName,
|
||||
creationDate: lastModified,
|
||||
fileType: fileType,
|
||||
fileSize: fileSize
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.imageMetadata.create({
|
||||
data: {
|
||||
imageId: image.id,
|
||||
format: metadata.format || "unknown",
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
space: metadata.space || "unknown",
|
||||
channels: metadata.channels || 0,
|
||||
depth: metadata.depth || "unknown",
|
||||
density: metadata.density ?? undefined,
|
||||
bitsPerSample: metadata.bitsPerSample ?? undefined,
|
||||
isProgressive: metadata.isProgressive ?? undefined,
|
||||
isPalette: metadata.isPalette ?? undefined,
|
||||
hasProfile: metadata.hasProfile ?? undefined,
|
||||
hasAlpha: metadata.hasAlpha ?? undefined,
|
||||
autoOrientW: metadata.autoOrient?.width ?? undefined,
|
||||
autoOrientH: metadata.autoOrient?.height ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.imageVariant.createMany({
|
||||
data: [
|
||||
{
|
||||
s3Key: originalKey,
|
||||
type: "original",
|
||||
height: metadata.height,
|
||||
width: metadata.width,
|
||||
fileExtension: metadata.format,
|
||||
mimeType: "image/" + metadata.format,
|
||||
sizeBytes: metadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: modifiedKey,
|
||||
type: "modified",
|
||||
height: modifiedMetadata.height,
|
||||
width: modifiedMetadata.width,
|
||||
fileExtension: modifiedMetadata.format,
|
||||
mimeType: "image/" + modifiedMetadata.format,
|
||||
sizeBytes: modifiedMetadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: resizedKey,
|
||||
type: "resized",
|
||||
height: resizedMetadata.height,
|
||||
width: resizedMetadata.width,
|
||||
fileExtension: resizedMetadata.format,
|
||||
mimeType: "image/" + resizedMetadata.format,
|
||||
sizeBytes: resizedMetadata.size,
|
||||
imageId: image.id
|
||||
},
|
||||
{
|
||||
s3Key: thumbnailKey,
|
||||
type: "thumbnail",
|
||||
height: thumbnailMetadata.height,
|
||||
width: thumbnailMetadata.width,
|
||||
fileExtension: thumbnailMetadata.format,
|
||||
mimeType: "image/" + thumbnailMetadata.format,
|
||||
sizeBytes: thumbnailMetadata.size,
|
||||
imageId: image.id
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
return image
|
||||
}
|
33
src/app/api/image/[...key]/route.ts
Normal file
33
src/app/api/image/[...key]/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { key: string[] } }) {
|
||||
const { key } = await params;
|
||||
const s3Key = key.join("/");
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: s3Key,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
|
||||
if (!response.Body) {
|
||||
return new Response("No body", { status: 500 });
|
||||
}
|
||||
|
||||
const contentType = response.ContentType ?? "application/octet-stream";
|
||||
|
||||
return new Response(response.Body as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Content-Disposition": "inline", // use 'attachment' to force download
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return new Response("Image not found", { status: 404 });
|
||||
}
|
||||
}
|
45
src/app/portfolio/edit/[id]/page.tsx
Normal file
45
src/app/portfolio/edit/[id]/page.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import DeleteImageButton from "@/components/portfolio/edit/DeleteImageButton";
|
||||
import EditImageForm from "@/components/portfolio/edit/EditImageForm";
|
||||
import ImageColors from "@/components/portfolio/edit/ImageColors";
|
||||
import ImageVariants from "@/components/portfolio/edit/ImageVariants";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function PortfolioEditPage({ params }: { params: { id: string } }) {
|
||||
const { id } = await params;
|
||||
|
||||
const image = await prisma.portfolioImage.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
metadata: true,
|
||||
categories: true,
|
||||
colors: { include: { color: true } },
|
||||
tags: true,
|
||||
variants: true
|
||||
}
|
||||
});
|
||||
|
||||
const categories = await prisma.portfolioCategory.findMany({ orderBy: { name: "asc" } });
|
||||
const tags = await prisma.portfolioTag.findMany({ orderBy: { name: "asc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
{image ? <EditImageForm image={image} tags={tags} categories={categories} /> : 'Image not found...'}
|
||||
<div className="mt-6">
|
||||
{image && <DeleteImageButton imageId={image.id} />}
|
||||
</div>
|
||||
<div>
|
||||
{image && <ImageVariants variants={image.variants} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
src/app/portfolio/page.tsx
Normal file
28
src/app/portfolio/page.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
|
||||
import ListImages from "@/components/portfolio/ListImages";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function PortfolioPage() {
|
||||
const images = await prisma.portfolioImage.findMany(
|
||||
{
|
||||
orderBy: [
|
||||
{ sortIndex: 'asc' },
|
||||
{ creationDate: 'desc' },
|
||||
{ name: 'asc' }]
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-4 justify-between pb-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Images</h1>
|
||||
<Link href="/portfolio/upload" 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" /> Upload new image
|
||||
</Link>
|
||||
</div>
|
||||
{images.length > 0 ? <ListImages images={images} /> : <p className="text-muted-foreground italic">No images found.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
10
src/app/portfolio/upload/page.tsx
Normal file
10
src/app/portfolio/upload/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import UploadImageForm from "@/components/portfolio/upload/UploadImageForm";
|
||||
|
||||
export default function PortfolioUploadPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Upload image</h1>
|
||||
<UploadImageForm />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -12,6 +12,11 @@ export default function TopNav() {
|
||||
<Link href="/">Home</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/portfolio">Portfolio</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/items/commissions/types">CommissionTypes</Link>
|
||||
|
61
src/components/portfolio/ListImages.tsx
Normal file
61
src/components/portfolio/ListImages.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { updateImageSortOrder } from "@/actions/portfolio/updateImageSortOrder";
|
||||
import { PortfolioImage } from "@/generated/prisma";
|
||||
import { SortableItem } from "@/types/SortableItem";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SortableImage } from "../sort/SortableImage";
|
||||
import { SortableList } from "../sort/SortableList";
|
||||
|
||||
|
||||
export default function ListImages({ images }: { images: PortfolioImage[] }) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const sortableItems: SortableItem[] = images.map(image => ({
|
||||
id: image.id,
|
||||
sortIndex: image.sortIndex,
|
||||
label: image.name || "",
|
||||
}));
|
||||
|
||||
const handleSortDefault = async () => {
|
||||
const sorted = [...sortableItems]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
|
||||
await updateImageSortOrder(sorted);
|
||||
};
|
||||
|
||||
const handleReorder = async (items: SortableItem[]) => {
|
||||
await updateImageSortOrder(items);
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<SortableList
|
||||
items={sortableItems}
|
||||
onReorder={handleReorder}
|
||||
onSortDefault={handleSortDefault}
|
||||
defaultSortLabel="Sort by name"
|
||||
renderItem={(item) => {
|
||||
const image = images.find(g => g.id === item.id)!;
|
||||
return (
|
||||
<SortableImage
|
||||
id={image.id}
|
||||
item={{
|
||||
id: image.id,
|
||||
name: image.name || "",
|
||||
fileKey: image.fileKey,
|
||||
altText: image.altText || image.name || "",
|
||||
published: image.published || false,
|
||||
creationDate: image.creationDate || undefined
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
26
src/components/portfolio/edit/DeleteImageButton.tsx
Normal file
26
src/components/portfolio/edit/DeleteImageButton.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { deleteImage } from "@/actions/portfolio/edit/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("/portfolio"); // redirect to image list or gallery
|
||||
} else {
|
||||
alert("Failed to delete image.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete Image
|
||||
</Button>
|
||||
);
|
||||
}
|
316
src/components/portfolio/edit/EditImageForm.tsx
Normal file
316
src/components/portfolio/edit/EditImageForm.tsx
Normal file
@ -0,0 +1,316 @@
|
||||
"use client"
|
||||
|
||||
import { updateImage } from "@/actions/portfolio/edit/updateImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import MultipleSelector from "@/components/ui/multiselect";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag } from "@/generated/prisma";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { imageSchema } from "@/schemas/imageSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format } from "date-fns";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type ImageWithItems = PortfolioImage & {
|
||||
metadata: ImageMetadata | null,
|
||||
colors: (
|
||||
ImageColor & {
|
||||
color: Color
|
||||
}
|
||||
)[],
|
||||
variants: ImageVariant[],
|
||||
categories: PortfolioCategory[],
|
||||
tags: PortfolioTag[],
|
||||
};
|
||||
|
||||
|
||||
export default function EditImageForm({ image, categories, tags }:
|
||||
{
|
||||
image: ImageWithItems,
|
||||
categories: PortfolioCategory[]
|
||||
tags: PortfolioTag[],
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof imageSchema>>({
|
||||
resolver: zodResolver(imageSchema),
|
||||
defaultValues: {
|
||||
fileKey: image.fileKey,
|
||||
originalFile: image.originalFile,
|
||||
nsfw: image.nsfw ?? false,
|
||||
published: image.nsfw ?? false,
|
||||
|
||||
altText: image.altText || "",
|
||||
description: image.description || "",
|
||||
fileType: image.fileType || "",
|
||||
name: image.name || "",
|
||||
slug: image.slug || "",
|
||||
type: image.type || "",
|
||||
fileSize: image.fileSize || undefined,
|
||||
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
|
||||
|
||||
tagIds: image.tags?.map(tag => tag.id) ?? [],
|
||||
categoryIds: image.categories?.map(cat => cat.id) ?? [],
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof imageSchema>) {
|
||||
const updatedImage = await updateImage(values, image.id)
|
||||
if (updatedImage) {
|
||||
toast.success("Image updated")
|
||||
router.push(`/portfolio`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image Key</FormLabel>
|
||||
<FormControl><Input {...field} disabled /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="originalFile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Original file</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nsfw"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>NSFW</FormLabel>
|
||||
<FormDescription>This image contains sensitive or adult content.</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Publish</FormLabel>
|
||||
<FormDescription>Will this image be published.</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="altText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alt Text</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl><Textarea {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image name</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image slug</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image type</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Filetype</FormLabel>
|
||||
<FormControl><Input {...field} disabled /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image fileSize</FormLabel>
|
||||
<FormControl><Input type="number" {...field} disabled /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="creationDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel>Creation Date</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={(date) => {
|
||||
field.onChange(date)
|
||||
}}
|
||||
initialFocus
|
||||
fromYear={1990}
|
||||
toYear={2030}
|
||||
captionLayout="dropdown"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tagIds"
|
||||
render={({ field }) => {
|
||||
const selectedOptions = tags
|
||||
.filter(tag => field.value?.includes(tag.id))
|
||||
.map(tag => ({ label: tag.name, value: tag.id }));
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormControl>
|
||||
<MultipleSelector
|
||||
defaultOptions={tags.map(tag => ({
|
||||
label: tag.name,
|
||||
value: tag.id,
|
||||
}))}
|
||||
placeholder="Select tags"
|
||||
hidePlaceholderWhenSelected
|
||||
selectFirstItem
|
||||
value={selectedOptions}
|
||||
onChange={(options) => {
|
||||
const ids = options.map(option => option.value);
|
||||
field.onChange(ids);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryIds"
|
||||
render={({ field }) => {
|
||||
const selectedOptions = categories
|
||||
.filter(cat => field.value?.includes(cat.id))
|
||||
.map(cat => ({ label: cat.name, value: cat.id }));
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Categories</FormLabel>
|
||||
<FormControl>
|
||||
<MultipleSelector
|
||||
defaultOptions={categories.map(cat => ({
|
||||
label: cat.name,
|
||||
value: cat.id,
|
||||
}))}
|
||||
placeholder="Select categories"
|
||||
hidePlaceholderWhenSelected
|
||||
selectFirstItem
|
||||
value={selectedOptions}
|
||||
onChange={(options) => {
|
||||
const ids = options.map(option => option.value);
|
||||
field.onChange(ids);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div >
|
||||
);
|
||||
}
|
50
src/components/portfolio/edit/ImageColors.tsx
Normal file
50
src/components/portfolio/edit/ImageColors.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { generateImageColors } from "@/actions/portfolio/edit/generateImageColors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Color, ImageColor } from "@/generated/prisma";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ColorWithItems = ImageColor & {
|
||||
color: Color
|
||||
};
|
||||
|
||||
export default function ImageColors({ colors: initialColors, imageId, fileKey, fileType }: { colors: ColorWithItems[], imageId: string, fileKey: string, fileType?: string }) {
|
||||
const [colors, setColors] = useState(initialColors);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleGenerate = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newColors = await generateImageColors(imageId, fileKey, fileType);
|
||||
setColors(newColors);
|
||||
toast.success("Colors extracted successfully");
|
||||
} catch (err) {
|
||||
toast.error("Failed to extract colors");
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold text-lg">Image Colors</h2>
|
||||
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
|
||||
{isPending ? "Extracting..." : "Generate Palette"}
|
||||
</Button>
|
||||
</div >
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map((item) => (
|
||||
<div
|
||||
key={`${item.imageId}-${item.type}`}
|
||||
className="w-10 h-10 rounded"
|
||||
style={{ backgroundColor: item.color?.hex ?? "#000000" }}
|
||||
title={`${item.type} – ${item.color?.hex}`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
21
src/components/portfolio/edit/ImageVariants.tsx
Normal file
21
src/components/portfolio/edit/ImageVariants.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { ImageVariant } from "@/generated/prisma";
|
||||
import { formatFileSize } from "@/utils/formatFileSize";
|
||||
import NextImage from "next/image";
|
||||
|
||||
export default function ImageVariants({ variants }: { variants: ImageVariant[] }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="font-semibold text-lg mb-2">Variants</h2>
|
||||
<div>
|
||||
{variants.map((variant) => (
|
||||
<div key={variant.id}>
|
||||
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
|
||||
{variant.s3Key && (
|
||||
<NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
93
src/components/portfolio/upload/UploadImageForm.tsx
Normal file
93
src/components/portfolio/upload/UploadImageForm.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { uploadImage } from "@/actions/portfolio/upload/uploadImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { imageUploadSchema } from "@/schemas/imageSchema";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
|
||||
export default function UploadImageForm() {
|
||||
const router = useRouter();
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<z.infer<typeof imageUploadSchema>>({
|
||||
resolver: zodResolver(imageUploadSchema),
|
||||
defaultValues: {
|
||||
file: undefined
|
||||
},
|
||||
})
|
||||
|
||||
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
setPreview(reader.result as string);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
form.setValue("file", files);
|
||||
};
|
||||
|
||||
async function onSubmit(values: z.infer<typeof imageUploadSchema>) {
|
||||
const image = await uploadImage(values)
|
||||
if (image) {
|
||||
toast.success("Image created")
|
||||
router.push(`/portfolio/edit/${image.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Choose image to upload</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => onFileChange(e)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit">Submit</Button>
|
||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="flex justify-center p-4">
|
||||
{
|
||||
preview ?
|
||||
<Image
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
88
src/components/sort/SortableImage.tsx
Normal file
88
src/components/sort/SortableImage.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
import { CheckCircle, Circle, GripVertical } from 'lucide-react';
|
||||
import NextImage from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
type SortableCardItemProps = {
|
||||
id: string;
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
fileKey: string;
|
||||
altText: string;
|
||||
published?: boolean;
|
||||
creationDate?: Date | string;
|
||||
};
|
||||
};
|
||||
|
||||
export function SortableImage({ id, item }: SortableCardItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const href = `/portfolio/edit/${item.id}`;
|
||||
|
||||
let dateDisplay = null;
|
||||
if (item.creationDate instanceof Date) {
|
||||
dateDisplay = item.creationDate.toLocaleDateString('de-DE');
|
||||
} else if (typeof item.creationDate === 'string') {
|
||||
const parsed = new Date(item.creationDate);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
dateDisplay = parsed.toLocaleDateString('de-DE');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="relative cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<Link href={href}>
|
||||
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
|
||||
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
|
||||
<NextImage
|
||||
src={`/api/image/thumbnail/${item.fileKey}.webp`}
|
||||
alt={item.altText ? item.altText : "Image"}
|
||||
fill
|
||||
className={clsx("object-cover transition duration-300")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 text-center">
|
||||
<h2 className="text-lg font-semibold truncate">{item.name}</h2>
|
||||
{dateDisplay && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{dateDisplay}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status icon in bottom-left corner */}
|
||||
<div className="absolute bottom-2 left-2 z-10 bg-white/80 rounded-full p-1">
|
||||
{item.published ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
103
src/components/sort/SortableItem.tsx
Normal file
103
src/components/sort/SortableItem.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import type { Color, Image, ImageColor } from '@/generated/prisma';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import NextImage from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
type SupportedTypes = 'gallery' | 'album' | 'artist' | 'category' | 'tag';
|
||||
|
||||
const pluralMap: Record<SupportedTypes, string> = {
|
||||
gallery: 'galleries',
|
||||
album: 'albums',
|
||||
artist: 'artists',
|
||||
category: 'categories',
|
||||
tag: 'tags',
|
||||
};
|
||||
|
||||
type SortableCardItemProps = {
|
||||
id: string;
|
||||
item: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'gallery' | 'album' | 'artist' | 'category' | 'tag';
|
||||
coverImage?: (Image & { colors?: (ImageColor & { color: Color })[] }) | null;
|
||||
count?: number;
|
||||
textLabel?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function SortableCardItem({ id, item }: SortableCardItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const href = `/${pluralMap[item.type]}/edit/${item.id}`;
|
||||
|
||||
const isVisualType = item.type === 'gallery' || item.type === 'album';
|
||||
|
||||
let countLabel = '';
|
||||
if (item.count !== undefined) {
|
||||
if (item.type === 'gallery') {
|
||||
countLabel = `${item.count} album${item.count !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
countLabel = `${item.count} image${item.count !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className="relative cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div
|
||||
{...listeners}
|
||||
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<Link href={href}>
|
||||
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
|
||||
{isVisualType ? (
|
||||
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
|
||||
{item.coverImage?.fileKey ? (
|
||||
<NextImage
|
||||
src={`/api/image/thumbnails/${item.coverImage.fileKey}.webp`}
|
||||
alt={item.coverImage.imageName}
|
||||
fill
|
||||
className={clsx("object-cover transition duration-300")}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
No cover image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={clsx("p-4", !isVisualType && "text-center")}>
|
||||
<h2 className={clsx("text-lg font-semibold truncate", isVisualType && "text-center")}>
|
||||
{item.name}
|
||||
</h2>
|
||||
{countLabel && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{countLabel}</p>
|
||||
)}
|
||||
{item.textLabel && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{item.textLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
85
src/components/sort/SortableList.tsx
Normal file
85
src/components/sort/SortableList.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { SortableItem as ItemType } from '@/types/SortableItem';
|
||||
|
||||
interface Props {
|
||||
items: ItemType[];
|
||||
onReorder: (items: ItemType[]) => void;
|
||||
onSortDefault?: () => void;
|
||||
defaultSortLabel?: string;
|
||||
renderItem: (item: ItemType) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function SortableList({
|
||||
items,
|
||||
onReorder,
|
||||
onSortDefault,
|
||||
defaultSortLabel = 'Sort by Name',
|
||||
renderItem,
|
||||
}: Props) {
|
||||
const [localItems, setLocalItems] = useState(items);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = localItems.findIndex(item => item.id === active.id);
|
||||
const newIndex = localItems.findIndex(item => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const reordered = arrayMove(localItems, oldIndex, newIndex).map((item, index) => ({
|
||||
...item,
|
||||
sortIndex: index * 10,
|
||||
}));
|
||||
|
||||
setLocalItems(reordered);
|
||||
onReorder(reordered);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{onSortDefault && (
|
||||
<button
|
||||
onClick={onSortDefault}
|
||||
className="px-4 py-2 text-sm font-medium bg-gray-200 rounded hover:bg-gray-300 text-primary-foreground"
|
||||
>
|
||||
{defaultSortLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={localItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
|
||||
{localItems.map(item => (
|
||||
<div key={item.id}>{renderItem(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
608
src/components/ui/multiselect.tsx
Normal file
608
src/components/ui/multiselect.tsx
Normal file
@ -0,0 +1,608 @@
|
||||
'use client';
|
||||
|
||||
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||
import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
/**
|
||||
* sync search. This search will not showing loadingIndicator.
|
||||
* The rest props are the same as async search.
|
||||
* i.e.: creatable, groupBy, delay.
|
||||
**/
|
||||
onSearchSync?: (value: string) => Option[];
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
maxSelected?: number;
|
||||
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
||||
onMaxSelected?: (maxLimit: number) => void;
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
selectFirstItem?: boolean;
|
||||
/** Allow user to create option when there is no option matched. */
|
||||
creatable?: boolean;
|
||||
/** Props of `Command` */
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
'value' | 'placeholder' | 'disabled'
|
||||
>;
|
||||
/** hide the clear all button. */
|
||||
hideClearAllButton?: boolean;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: Option[];
|
||||
input: HTMLInputElement;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
if (options.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (!groupBy) {
|
||||
return {
|
||||
'': options,
|
||||
};
|
||||
}
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || '';
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [];
|
||||
}
|
||||
groupOption[key].push(option);
|
||||
});
|
||||
return groupOption;
|
||||
}
|
||||
|
||||
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
||||
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
||||
|
||||
for (const [key, value] of Object.entries(cloneOption)) {
|
||||
cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value));
|
||||
}
|
||||
return cloneOption;
|
||||
}
|
||||
|
||||
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
||||
for (const [, value] of Object.entries(groupOption)) {
|
||||
if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||
*
|
||||
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
||||
**/
|
||||
const CommandEmpty = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn('py-6 text-center text-sm', className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = 'CommandEmpty';
|
||||
|
||||
const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorProps>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
defaultOptions: arrayDefaultOptions = [],
|
||||
options: arrayOptions,
|
||||
delay,
|
||||
onSearch,
|
||||
onSearchSync,
|
||||
loadingIndicator,
|
||||
emptyIndicator,
|
||||
maxSelected = Number.MAX_SAFE_INTEGER,
|
||||
onMaxSelected,
|
||||
hidePlaceholderWhenSelected,
|
||||
disabled,
|
||||
groupBy,
|
||||
className,
|
||||
badgeClassName,
|
||||
selectFirstItem = true,
|
||||
creatable = false,
|
||||
triggerSearchOnFocus = false,
|
||||
commandProps,
|
||||
inputProps,
|
||||
hideClearAllButton = false,
|
||||
}: MultipleSelectorProps,
|
||||
ref: React.Ref<MultipleSelectorRef>,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [onScrollbar, setOnScrollbar] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null); // Added this
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectedValue: [...selected],
|
||||
input: inputRef.current as HTMLInputElement,
|
||||
focus: () => inputRef?.current?.focus(),
|
||||
reset: () => setSelected([]),
|
||||
}),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
inputRef.current.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnselect = React.useCallback(
|
||||
(option: Option) => {
|
||||
const newOptions = selected.filter((s) => s.value !== option.value);
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
},
|
||||
[onChange, selected],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (input.value === '' && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If there is a last item and it is not fixed, we can remove it.
|
||||
if (lastSelectOption && !lastSelectOption.fixed) {
|
||||
handleUnselect(lastSelectOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is not a default behavior of the <input /> field
|
||||
if (e.key === 'Escape') {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleUnselect, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
/** If `onSearch` is provided, do not trigger options updated. */
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]);
|
||||
|
||||
useEffect(() => {
|
||||
/** sync search */
|
||||
|
||||
const doSearchSync = () => {
|
||||
const res = onSearchSync?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearchSync || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
doSearchSync();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
doSearchSync();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
/** async search */
|
||||
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearch || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
await doSearch();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
await doSearch();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
||||
selected.find((s) => s.value === inputValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Item = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value: string) => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, { value, label: value }];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
>
|
||||
{`Create "${inputValue}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// For normal creatable
|
||||
if (!onSearch && inputValue.length > 0) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
// For async search creatable. avoid showing creatable item before loading at first.
|
||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const EmptyItem = React.useCallback(() => {
|
||||
if (!emptyIndicator) return undefined;
|
||||
|
||||
// For async search that showing emptyIndicator
|
||||
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
||||
return (
|
||||
<CommandItem value="-" disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => removePickedOption(options, selected),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
}
|
||||
|
||||
if (creatable) {
|
||||
return (value: string, search: string) => {
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
// Using default filter in `cmdk`. We don't have to provide it.
|
||||
return undefined;
|
||||
}, [creatable, commandProps?.filter]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
ref={dropdownRef}
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn('h-auto overflow-visible bg-transparent', commandProps?.className)}
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined ? commandProps.shouldFilter : !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
filter={commandFilter()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
|
||||
{
|
||||
'px-3 py-2': selected.length !== 0,
|
||||
'cursor-text': !disabled && selected.length !== 0,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-wrap gap-1">
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
|
||||
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
(disabled || option.fixed) && 'hidden',
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!onScrollbar) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
placeholder={hidePlaceholderWhenSelected && selected.length !== 0 ? '' : placeholder}
|
||||
className={cn(
|
||||
'flex-1 bg-transparent outline-none placeholder:text-muted-foreground',
|
||||
{
|
||||
'w-full': hidePlaceholderWhenSelected,
|
||||
'px-3 py-2': selected.length === 0,
|
||||
'ml-1': selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(selected.filter((s) => s.fixed));
|
||||
onChange?.(selected.filter((s) => s.fixed));
|
||||
}}
|
||||
className={cn(
|
||||
'absolute ltr:right-0 rtl:left-0 h-6 w-6 p-0',
|
||||
(hideClearAllButton ||
|
||||
disabled ||
|
||||
selected.length < 1 ||
|
||||
selected.filter((s) => s.fixed).length === selected.length) &&
|
||||
'hidden',
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
<CommandList
|
||||
className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in"
|
||||
onMouseLeave={() => {
|
||||
setOnScrollbar(false);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setOnScrollbar(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
inputRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>{loadingIndicator}</>
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
|
||||
<>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue('');
|
||||
const newOptions = [...selected, option];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
option.disable && 'cursor-default text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = 'MultipleSelector';
|
||||
export default MultipleSelector;
|
21
src/lib/s3.ts
Normal file
21
src/lib/s3.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
export const s3 = new S3Client({
|
||||
region: "us-east-1",
|
||||
endpoint: "http://10.0.20.11:9010",
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: "fellies",
|
||||
secretAccessKey: "XCJ7spqxWZhVn8tkYnfVBFbz2cRKYxPAfeQeIdPRp1",
|
||||
},
|
||||
});
|
||||
|
||||
export async function getSignedImageUrl(key: string, expiresInSec = 3600) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(s3, command, { expiresIn: expiresInSec });
|
||||
}
|
28
src/schemas/imageSchema.ts
Normal file
28
src/schemas/imageSchema.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const imageUploadSchema = z.object({
|
||||
file: z
|
||||
.custom<FileList>()
|
||||
.refine((files) => files instanceof FileList && files.length > 0, {
|
||||
message: "Image file is required",
|
||||
}),
|
||||
})
|
||||
|
||||
export const imageSchema = z.object({
|
||||
fileKey: z.string().min(1, "File key is required"),
|
||||
originalFile: z.string().min(1, "Original file is required"),
|
||||
nsfw: z.boolean(),
|
||||
published: z.boolean(),
|
||||
|
||||
altText: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
creationDate: z.date().optional(),
|
||||
|
||||
categoryIds: z.array(z.string()).optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
})
|
6
src/types/SortableItem.ts
Normal file
6
src/types/SortableItem.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface SortableItem {
|
||||
id: string;
|
||||
sortIndex: number;
|
||||
label: string; // e.g., name, displayName, or handle
|
||||
secondary?: string | boolean; // optional (e.g. isPrimary)
|
||||
}
|
4
src/types/VibrantSwatch.ts
Normal file
4
src/types/VibrantSwatch.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type VibrantSwatch = {
|
||||
_rgb: [number, number, number];
|
||||
hex?: string;
|
||||
};
|
9
src/utils/formatFileSize.ts
Normal file
9
src/utils/formatFileSize.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
const mb = kb / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
}
|
22
src/utils/getImageBufferFromS3.ts
Normal file
22
src/utils/getImageBufferFromS3.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { s3 } from "@/lib/s3";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { Readable } from "stream";
|
||||
|
||||
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> {
|
||||
const type = fileType ? fileType.split("/")[1] : "webp";
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: "gaertan",
|
||||
Key: `original/${fileKey}.${type}`,
|
||||
});
|
||||
|
||||
const response = await s3.send(command);
|
||||
const stream = response.Body as Readable;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
96
src/utils/uploadHelper.ts
Normal file
96
src/utils/uploadHelper.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
// export function generatePaletteName(tones: Tone[]): string {
|
||||
// const hexString = tones.map(t => t.hex.toLowerCase()).join('');
|
||||
// const hash = crypto.createHash('sha256').update(hexString).digest('hex');
|
||||
// return `palette-${hash.slice(0, 8)}`;
|
||||
// }
|
||||
|
||||
export function generateColorName(hex: string): string {
|
||||
const hash = crypto.createHash("sha256").update(hex.toLowerCase()).digest("hex");
|
||||
return `color-${hash.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
// export function generateExtractColorName(hex: string, hue?: number, sat?: number, area?: number): string {
|
||||
// const data = `${hex.toLowerCase()}-${hue ?? 0}-${sat ?? 0}-${area ?? 0}`;
|
||||
// const hash = crypto.createHash("sha256").update(data).digest("hex");
|
||||
// return `extract-${hash.slice(0, 8)}`;
|
||||
// }
|
||||
|
||||
export function rgbToHex(rgb: number[]): string {
|
||||
return `#${rgb
|
||||
.map((val) => Math.round(val).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export function argbToHex(argb: number): string {
|
||||
return `#${argb.toString(16).slice(2).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
// export function extractPaletteTones(
|
||||
// palette: TonalPalette, tones: number[] = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
||||
// ) {
|
||||
// return tones.map((t) => ({
|
||||
// tone: t,
|
||||
// hex: argbToHex(palette.tone(t)),
|
||||
// }));
|
||||
// }
|
||||
|
||||
// export async function upsertPalettes(tones: Tone[], imageId: string, type: string) {
|
||||
// const paletteName = generatePaletteName(tones);
|
||||
|
||||
// const existingPalette = await prisma.colorPalette.findFirst({
|
||||
// where: { name: paletteName },
|
||||
// include: { items: true },
|
||||
// });
|
||||
|
||||
// //
|
||||
// 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;
|
||||
// }
|
Reference in New Issue
Block a user