4 Commits

Author SHA1 Message Date
2015ea6f2e Add custom commission types 2026-02-01 16:21:20 +01:00
e869f19142 Add commission type example image 2026-01-31 16:37:24 +01:00
51cfde4d78 Fix docker file for prod 2026-01-31 10:48:37 +01:00
88bb301e84 Add new gallery variant 2026-01-31 01:34:13 +01:00
33 changed files with 1746 additions and 34 deletions

View File

@ -45,3 +45,12 @@ USER nextjs
EXPOSE 3000
CMD ["bun", "./server.js"]
# One-off migrations image (run at deploy time with DATABASE_URL)
FROM base AS migrate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY prisma ./prisma
COPY prisma.config.ts package.json ./
ENV NODE_ENV=production
CMD ["bunx", "prisma", "migrate", "deploy"]

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CommissionGuidelines" ADD COLUMN "exampleImageUrl" TEXT;

View File

@ -0,0 +1,65 @@
-- CreateTable
CREATE TABLE "CommissionCustomCard" (
"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 NOT NULL,
"description" TEXT,
"referenceImageUrl" TEXT,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"isSpecialOffer" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "CommissionCustomCard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomCardOption" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"cardId" TEXT NOT NULL,
"optionId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionCustomCardOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomCardExtra" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"cardId" TEXT NOT NULL,
"extraId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionCustomCardExtra_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommissionCustomCard_isVisible_sortIndex_idx" ON "CommissionCustomCard"("isVisible", "sortIndex");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionCustomCardOption_cardId_optionId_key" ON "CommissionCustomCardOption"("cardId", "optionId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionCustomCardExtra_cardId_extraId_key" ON "CommissionCustomCardExtra"("cardId", "extraId");
-- AddForeignKey
ALTER TABLE "CommissionCustomCardOption" ADD CONSTRAINT "CommissionCustomCardOption_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "CommissionCustomCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardOption" ADD CONSTRAINT "CommissionCustomCardOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardExtra" ADD CONSTRAINT "CommissionCustomCardExtra_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "CommissionCustomCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardExtra" ADD CONSTRAINT "CommissionCustomCardExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "CommissionRequest" ADD COLUMN "customCardId" TEXT;
-- AddForeignKey
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_customCardId_fkey" FOREIGN KEY ("customCardId") REFERENCES "CommissionCustomCard"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -271,6 +271,26 @@ model CommissionType {
requests CommissionRequest[]
}
model CommissionCustomCard {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
referenceImageUrl String?
isVisible Boolean @default(true)
isSpecialOffer Boolean @default(false)
options CommissionCustomCardOption[]
extras CommissionCustomCardExtra[]
requests CommissionRequest[]
@@index([isVisible, sortIndex])
}
model CommissionOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -282,6 +302,7 @@ model CommissionOption {
description String?
types CommissionTypeOption[]
customCards CommissionCustomCardOption[]
requests CommissionRequest[]
}
@ -316,6 +337,7 @@ model CommissionExtra {
requests CommissionRequest[]
types CommissionTypeExtra[]
customCards CommissionCustomCardExtra[]
}
model CommissionTypeExtra {
@ -337,6 +359,25 @@ model CommissionTypeExtra {
@@unique([typeId, extraId])
}
model CommissionCustomCardOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
cardId String
optionId String
priceRange String?
pricePercent Float?
price Float?
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
option CommissionOption @relation(fields: [optionId], references: [id])
@@unique([cardId, optionId])
}
model CommissionCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -368,6 +409,25 @@ model CommissionTypeCustomInput {
@@unique([typeId, customInputId])
}
model CommissionCustomCardExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
cardId String
extraId String
priceRange String?
pricePercent Float?
price Float?
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
extra CommissionExtra @relation(fields: [extraId], references: [id])
@@unique([cardId, extraId])
}
model CommissionRequest {
id String @id @default(cuid())
index Int @default(autoincrement())
@ -386,8 +446,10 @@ model CommissionRequest {
optionId String?
typeId String?
customCardId String?
option CommissionOption? @relation(fields: [optionId], references: [id])
type CommissionType? @relation(fields: [typeId], references: [id])
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
extras CommissionExtra[]
files CommissionRequestFile[]
@ -399,6 +461,7 @@ model CommissionGuidelines {
updatedAt DateTime @updatedAt
markdown String
exampleImageUrl String?
isActive Boolean @default(true)
@@index([isActive])

View File

@ -0,0 +1,130 @@
"use server";
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
const GALLERY_TARGET_SIZE = 300;
export async function generateGalleryVariant(
artworkId: string,
opts?: { force?: boolean },
) {
const artwork = await prisma.artwork.findUnique({
where: { id: artworkId },
include: { file: true, variants: true },
});
if (!artwork || !artwork.file) {
throw new Error("Artwork or file not found");
}
const existing = artwork.variants.find((v) => v.type === "gallery");
if (existing && !opts?.force) {
return { ok: true, skipped: true, variantId: existing.id };
}
const source =
artwork.variants.find((v) => v.type === "modified") ??
artwork.variants.find((v) => v.type === "original");
if (!source?.s3Key) {
throw new Error("Missing source variant");
}
const buffer = await getImageBufferFromS3Key(source.s3Key);
const srcMeta = await sharp(buffer).metadata();
const { width, height } = srcMeta;
let resizeOptions: { width?: number; height?: number };
if (width && height) {
resizeOptions =
height < width
? { height: GALLERY_TARGET_SIZE }
: { width: GALLERY_TARGET_SIZE };
} else {
resizeOptions = { height: GALLERY_TARGET_SIZE };
}
const galleryBuffer = await sharp(buffer)
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat("webp")
.toBuffer();
const galleryMetadata = await sharp(galleryBuffer).metadata();
const galleryKey = `gallery/${artwork.file.fileKey}.webp`;
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: galleryKey,
Body: galleryBuffer,
ContentType: "image/" + galleryMetadata.format,
}),
);
const variant = await prisma.fileVariant.upsert({
where: { artworkId_type: { artworkId: artwork.id, type: "gallery" } },
create: {
s3Key: galleryKey,
type: "gallery",
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
artworkId: artwork.id,
},
update: {
s3Key: galleryKey,
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
},
});
return { ok: true, skipped: false, variantId: variant.id };
}
export async function generateGalleryVariantsMissing(args?: {
limit?: number;
}) {
const limit = Math.min(Math.max(args?.limit ?? 20, 1), 100);
const artworks = await prisma.artwork.findMany({
where: { variants: { none: { type: "gallery" } } },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit,
select: { id: true },
});
const results: Array<{ artworkId: string; ok: boolean; error?: string }> = [];
for (const a of artworks) {
try {
await generateGalleryVariant(a.id);
results.push({ artworkId: a.id, ok: true });
} catch (err) {
results.push({
artworkId: a.id,
ok: false,
error: err instanceof Error ? err.message : "Failed",
});
}
}
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
return {
picked: artworks.length,
processed: results.length,
ok,
failed,
results,
};
}

View File

@ -0,0 +1,24 @@
"use server";
import { prisma } from "@/lib/prisma";
export type GalleryVariantStats = {
total: number;
withGallery: number;
missing: number;
};
export async function getGalleryVariantStats(): Promise<GalleryVariantStats> {
const [total, withGallery] = await Promise.all([
prisma.artwork.count(),
prisma.artwork.count({
where: { variants: { some: { type: "gallery" } } },
}),
]);
return {
total,
withGallery,
missing: total - withGallery,
};
}

View File

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

View File

@ -0,0 +1,94 @@
"use server";
import { s3 } from "@/lib/s3";
import {
DeleteObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} from "@aws-sdk/client-s3";
const PREFIX = "commissions/custom-cards/";
export type CommissionCustomCardImageItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}
function sanitizeFilename(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export async function listCommissionCustomCardImages(): Promise<
CommissionCustomCardImageItem[]
> {
const command = new ListObjectsV2Command({
Bucket: `${process.env.BUCKET_NAME}`,
Prefix: PREFIX,
});
const res = await s3.send(command);
return (
res.Contents?.filter((obj) => obj.Key && obj.Key !== PREFIX).map((obj) => {
const key = obj.Key as string;
return {
key,
url: buildImageUrl(key),
size: obj.Size ?? null,
lastModified: obj.LastModified?.toISOString() ?? null,
};
}) ?? []
);
}
export async function uploadCommissionCustomCardImage(
formData: FormData
): Promise<CommissionCustomCardImageItem> {
const file = formData.get("file");
if (!(file instanceof File)) {
throw new Error("Missing file");
}
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are allowed");
}
const safeName = sanitizeFilename(file.name || "custom-card");
const key = `${PREFIX}${Date.now()}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
return {
key,
url: buildImageUrl(key),
size: file.size,
lastModified: new Date().toISOString(),
};
}
export async function deleteCommissionCustomCardImage(key: string) {
if (!key.startsWith(PREFIX)) {
throw new Error("Invalid key");
}
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
})
);
}

View File

@ -0,0 +1,52 @@
"use server";
import { prisma } from "@/lib/prisma";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
export async function createCommissionCustomCard(
formData: CommissionCustomCardValues
) {
const parsed = commissionCustomCardSchema.safeParse(formData);
if (!parsed.success) {
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data;
const created = await prisma.commissionCustomCard.create({
data: {
name: data.name,
description: data.description,
referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false,
options: {
create:
data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price,
pricePercent: opt.pricePercent,
priceRange: opt.priceRange,
sortIndex: index,
})) ?? [],
},
extras: {
create:
data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price,
pricePercent: ext.pricePercent,
priceRange: ext.priceRange,
sortIndex: index,
})) ?? [],
},
},
});
return created;
}

View File

@ -0,0 +1,51 @@
"use server";
import { prisma } from "@/lib/prisma";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
export async function updateCommissionCustomCard(
id: string,
rawData: CommissionCustomCardValues
) {
const data = commissionCustomCardSchema.parse(rawData);
const updated = await prisma.commissionCustomCard.update({
where: { id },
data: {
name: data.name,
description: data.description,
referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false,
options: {
deleteMany: {},
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price ?? null,
pricePercent: opt.pricePercent ?? null,
priceRange: opt.priceRange ?? null,
sortIndex: index,
})),
},
extras: {
deleteMany: {},
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price ?? null,
pricePercent: ext.pricePercent ?? null,
priceRange: ext.priceRange ?? null,
sortIndex: index,
})),
},
},
include: {
options: true,
extras: true,
},
});
return updated;
}

View File

@ -0,0 +1,16 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function updateCommissionCustomCardSortOrder(
items: { id: string; sortIndex: number }[]
) {
await prisma.$transaction(
items.map((item) =>
prisma.commissionCustomCard.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -0,0 +1,92 @@
"use server";
import { s3 } from "@/lib/s3";
import {
DeleteObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} from "@aws-sdk/client-s3";
const PREFIX = "commissions/examples/";
export type CommissionExampleItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}
function sanitizeFilename(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export async function listCommissionExamples(): Promise<CommissionExampleItem[]> {
const command = new ListObjectsV2Command({
Bucket: `${process.env.BUCKET_NAME}`,
Prefix: PREFIX,
});
const res = await s3.send(command);
return (
res.Contents?.filter((obj) => obj.Key && obj.Key !== PREFIX).map((obj) => {
const key = obj.Key as string;
return {
key,
url: buildImageUrl(key),
size: obj.Size ?? null,
lastModified: obj.LastModified?.toISOString() ?? null,
};
}) ?? []
);
}
export async function uploadCommissionExample(
formData: FormData
): Promise<CommissionExampleItem> {
const file = formData.get("file");
if (!(file instanceof File)) {
throw new Error("Missing file");
}
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are allowed");
}
const safeName = sanitizeFilename(file.name || "example");
const key = `${PREFIX}${Date.now()}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
return {
key,
url: buildImageUrl(key),
size: file.size,
lastModified: new Date().toISOString(),
};
}
export async function deleteCommissionExample(key: string) {
if (!key.startsWith(PREFIX)) {
throw new Error("Invalid key");
}
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
})
);
}

View File

@ -2,10 +2,21 @@
import { prisma } from "@/lib/prisma";
export async function getActiveGuidelines(): Promise<string | null> {
export async function getActiveGuidelines(): Promise<{
markdown: string | null;
exampleImageUrl: string | null;
}> {
const guidelines = await prisma.commissionGuidelines.findFirst({
where: { isActive: true },
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
select: {
markdown: true,
exampleImageUrl: true,
},
});
return guidelines?.markdown ?? null;
return {
markdown: guidelines?.markdown ?? null,
exampleImageUrl: guidelines?.exampleImageUrl ?? null,
};
}

View File

@ -2,7 +2,7 @@
import { prisma } from "@/lib/prisma";
export async function saveGuidelines(markdown: string) {
export async function saveGuidelines(markdown: string, exampleImageUrl: string | null) {
await prisma.commissionGuidelines.updateMany({
where: { isActive: true },
data: { isActive: false },
@ -11,6 +11,7 @@ export async function saveGuidelines(markdown: string) {
await prisma.commissionGuidelines.create({
data: {
markdown,
exampleImageUrl: exampleImageUrl || null,
},
});
}

View File

@ -8,7 +8,10 @@ import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
export async function createImageFromFile(imageFile: File, opts?: { originalName?: string, colorMode?: "inline" | "defer" | "off" }) {
export async function createImageFromFile(
imageFile: File,
opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" },
) {
if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null;
@ -29,6 +32,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
const modifiedKey = `modified/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnail/${fileKey}.webp`;
const galleryKey = `gallery/${fileKey}.webp`;
const sharpData = sharp(buffer);
const metadata = await sharpData.metadata();
@ -40,7 +44,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: originalKey,
Body: buffer,
ContentType: "image/" + metadata.format,
})
}),
);
//--- Modified file
@ -53,7 +57,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: modifiedKey,
Body: modifiedBuffer,
ContentType: "image/" + modifiedMetadata.format,
})
}),
);
//--- Resized file
@ -62,7 +66,8 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
let resizeOptions: { width?: number; height?: number };
if (width && height) {
resizeOptions = height < width ? { height: targetSize } : { width: targetSize };
resizeOptions =
height < width ? { height: targetSize } : { width: targetSize };
} else {
resizeOptions = { height: targetSize };
}
@ -80,7 +85,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: resizedKey,
Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format,
})
}),
);
//--- Thumbnail file
@ -88,7 +93,10 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
let thumbnailOptions: { width?: number; height?: number };
if (width && height) {
thumbnailOptions = height < width ? { height: thumbnailTargetSize } : { width: thumbnailTargetSize };
thumbnailOptions =
height < width
? { height: thumbnailTargetSize }
: { width: thumbnailTargetSize };
} else {
thumbnailOptions = { height: thumbnailTargetSize };
}
@ -106,7 +114,36 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: thumbnailKey,
Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format,
})
}),
);
//--- Gallery file
const galleryTargetSize = 300;
let galleryOptions: { width?: number; height?: number };
if (width && height) {
galleryOptions =
height < width
? { height: galleryTargetSize }
: { width: galleryTargetSize };
} else {
galleryOptions = { height: galleryTargetSize };
}
const galleryBuffer = await sharp(modifiedBuffer)
.resize({ ...galleryOptions, withoutEnlargement: true })
.toFormat("webp")
.toBuffer();
const galleryMetadata = await sharp(galleryBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: galleryKey,
Body: galleryBuffer,
ContentType: "image/" + galleryMetadata.format,
}),
);
const fileRecord = await prisma.fileData.create({
@ -193,6 +230,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
sizeBytes: thumbnailMetadata.size,
artworkId: artworkRecord.id,
},
{
s3Key: galleryKey,
type: "gallery",
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
artworkId: artworkRecord.id,
},
],
});
@ -206,6 +253,5 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
// (nothing else to do here)
}
return artworkRecord;
}

View File

@ -28,7 +28,7 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
{item && <DeleteArtworkButton artworkId={item.id} />}
</div>
<div>
{item && <ArtworkVariants variants={item.variants} />}
{item && <ArtworkVariants artworkId={item.id} variants={item.variants} />}
</div>
</div>
<div className="space-y-6">

View File

@ -1,4 +1,5 @@
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
import { ArtworkGalleryVariantProcessor } from "@/components/artworks/ArtworkGalleryVariantProcessor";
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
import { getArtworksPage } from "@/lib/queryArtworks";
@ -59,6 +60,7 @@ export default async function ArtworksPage({
<h1 className="text-2xl font-bold">Artworks</h1>
{/* <ProcessArtworkColorsButton /> */}
<ArtworkColorProcessor />
<ArtworkGalleryVariantProcessor />
<ArtworksTable />
</div>
// <div>

View File

@ -0,0 +1,43 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import EditCustomCardForm from "@/components/commissions/customCards/EditCustomCardForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export default async function CommissionCustomCardEditPage({
params,
}: {
params: { id: string };
}) {
const { id } = await params;
const [card, options, extras, images] = await Promise.all([
prisma.commissionCustomCard.findUnique({
where: { id },
include: {
options: { orderBy: { sortIndex: "asc" } },
extras: { orderBy: { sortIndex: "asc" } },
},
}),
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
]);
if (!card) {
notFound();
}
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Edit Custom Commission Card</h1>
</div>
<EditCustomCardForm
card={card}
allOptions={options}
allExtras={extras}
images={images}
/>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import NewCustomCardForm from "@/components/commissions/customCards/NewCustomCardForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionCustomCardsNewPage() {
const [options, extras, images] = await Promise.all([
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
]);
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Custom Commission Card</h1>
</div>
<NewCustomCardForm options={options} extras={extras} images={images} />
</div>
);
}

View File

@ -0,0 +1,34 @@
import ListCustomCards from "@/components/commissions/customCards/ListCustomCards";
import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function CommissionCustomCardsPage() {
const cards = await prisma.commissionCustomCard.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Custom Commission Cards</h1>
<Link
href="/commissions/custom-cards/new"
className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded"
>
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" />
Add new Card
</Link>
</div>
{cards && cards.length > 0 ? (
<ListCustomCards cards={cards} />
) : (
<p className="text-muted-foreground italic">No custom cards found.</p>
)}
</div>
);
}

View File

@ -1,8 +1,12 @@
import { listCommissionExamples } from "@/actions/commissions/examples";
import { getActiveGuidelines } from "@/actions/commissions/guidelines/getGuidelines";
import GuidelinesEditor from "@/components/commissions/guidelines/Editor";
export default async function CommissionGuidelinesPage() {
const markdown = await getActiveGuidelines();
const [{ markdown, exampleImageUrl }, examples] = await Promise.all([
getActiveGuidelines(),
listCommissionExamples(),
]);
return (
<div>
@ -10,7 +14,11 @@ export default async function CommissionGuidelinesPage() {
<h1 className="text-2xl font-bold mb-4">Commission Guidelines</h1>
</div>
<div className="space-y-4 p-1 border rounded-xl bg-muted/20">
<GuidelinesEditor markdown={markdown} />
<GuidelinesEditor
markdown={markdown}
exampleImageUrl={exampleImageUrl}
examples={examples}
/>
</div>
</div>
);

View File

@ -7,6 +7,7 @@ import { z } from "zod/v4";
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
customCardId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
extraIds: z.array(z.string().min(1)).default([]),
@ -14,6 +15,23 @@ const payloadSchema = z.object({
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
}).superRefine((data, ctx) => {
const hasType = Boolean(data.typeId);
const hasCustom = Boolean(data.customCardId);
if (!hasType && !hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Missing commission type or custom card",
});
}
if (hasType && hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Only one of typeId or customCardId is allowed",
});
}
});
function safeJsonParse(input: string) {
@ -83,6 +101,7 @@ export async function POST(request: Request) {
message: payload.data.message,
typeId: payload.data.typeId ?? null,
customCardId: payload.data.customCardId ?? null,
optionId: payload.data.optionId ?? null,
ipAddress,

View File

@ -0,0 +1,62 @@
"use client";
import { generateGalleryVariantsMissing } from "@/actions/artworks/generateGalleryVariant";
import { getGalleryVariantStats } from "@/actions/artworks/getGalleryVariantStats";
import { Button } from "@/components/ui/button";
import * as React from "react";
export function ArtworkGalleryVariantProcessor() {
const [stats, setStats] = React.useState<Awaited<
ReturnType<typeof getGalleryVariantStats>
> | null>(null);
const [loading, setLoading] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
const refreshStats = React.useCallback(async () => {
const s = await getGalleryVariantStats();
setStats(s);
}, []);
React.useEffect(() => {
void refreshStats();
}, [refreshStats]);
const run = async () => {
setLoading(true);
setMsg(null);
try {
const res = await generateGalleryVariantsMissing({ limit: 50 });
setMsg(`Processed ${res.processed}: ${res.ok} ok, ${res.failed} failed`);
await refreshStats();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
};
const done = !!stats && stats.missing === 0;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<Button onClick={run} disabled={loading || done}>
{done
? "All gallery variants present"
: loading
? "Generating…"
: "Generate missing gallery variants"}
</Button>
{stats && (
<span className="text-sm text-muted-foreground">
Ready {stats.withGallery}/{stats.total}
{stats.missing > 0 && ` · Missing ${stats.missing}`}
</span>
)}
</div>
{msg && <p className="text-sm text-muted-foreground">{msg}</p>}
</div>
);
}

View File

@ -1,12 +1,19 @@
import { FileVariant } from "@/generated/prisma/client";
"use client";
import { generateGalleryVariant } from "@/actions/artworks/generateGalleryVariant";
import { Button } from "@/components/ui/button";
import type { FileVariant } from "@/generated/prisma/client";
import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
const ORDER: Record<string, number> = {
thumbnail: 0,
resized: 1,
modified: 2,
original: 3,
gallery: 1,
resized: 2,
modified: 3,
original: 4,
};
function byVariantOrder(a: FileVariant, b: FileVariant) {
@ -16,18 +23,54 @@ function byVariantOrder(a: FileVariant, b: FileVariant) {
return a.type.localeCompare(b.type);
}
export default function ArtworkVariants({ variants }: { variants: FileVariant[] }) {
export default function ArtworkVariants({
artworkId,
variants,
}: {
artworkId: string;
variants: FileVariant[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const hasGallery = variants.some((v) => v.type === "gallery");
const sorted = [...variants].sort(byVariantOrder);
return (
<>
<h2 className="font-semibold text-lg mb-2">Variants</h2>
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="font-semibold text-lg">Variants</h2>
{!hasGallery ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={isPending}
onClick={() =>
startTransition(async () => {
await generateGalleryVariant(artworkId);
router.refresh();
})
}
>
{isPending ? "Generating..." : "Generate gallery"}
</Button>
) : null}
</div>
<div>
{sorted.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>
<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" />
<NextImage
src={`/api/image/${variant.s3Key}`}
alt={variant.s3Key}
width={variant.width}
height={variant.height}
className="rounded shadow max-w-md"
/>
)}
</div>
))}

View File

@ -0,0 +1,116 @@
"use client";
import {
deleteCommissionCustomCardImage,
uploadCommissionCustomCardImage,
} from "@/actions/commissions/customCards/images";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
} from "@/components/ui/form";
import type { CommissionCustomCardValues } from "@/schemas/commissionCustomCard";
import Image from "next/image";
import { useMemo, useTransition } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
type Props = {
form: UseFormReturn<CommissionCustomCardValues>;
initialImages: { key: string; url: string }[];
};
export function CustomCardImagePicker({ form }: Props) {
const [isPending, startTransition] = useTransition();
const referenceImageUrl = useWatch({
control: form.control,
name: "referenceImageUrl",
});
const previewUrl = useMemo(() => {
if (!referenceImageUrl) return "";
return referenceImageUrl;
}, [referenceImageUrl]);
const handleUpload = (file: File) => {
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const item = await uploadCommissionCustomCardImage(fd);
form.setValue("referenceImageUrl", item.url, { shouldDirty: true });
});
};
const handleDelete = () => {
const url = referenceImageUrl ?? "";
const key = url.replace(/^\/api\/image\//, "");
const decodedKey = decodeURIComponent(key);
if (!decodedKey) return;
if (!window.confirm("Delete this image from S3?")) return;
startTransition(async () => {
await deleteCommissionCustomCardImage(decodedKey);
form.setValue("referenceImageUrl", null, { shouldDirty: true });
});
};
return (
<FormItem>
<FormLabel>Reference image</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<input type="hidden" {...form.register("referenceImageUrl")} />
{previewUrl ? (
<div className="flex flex-col gap-2">
<div className="relative w-full max-w-md overflow-hidden rounded-lg border border-border/60 bg-muted/40">
<Image
src={previewUrl}
alt="Reference preview"
width={900}
height={600}
className="h-auto w-full object-cover"
/>
</div>
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
Open full size
</a>
</div>
) : (
<p className="text-sm text-muted-foreground">No image selected.</p>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.currentTarget.value = "";
}}
/>
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={!referenceImageUrl || isPending}
>
Delete image
</Button>
</div>
</div>
</FormControl>
<FormDescription>
Upload and preview a reference image stored in the custom card bucket folder.
</FormDescription>
</FormItem>
);
}

View File

@ -0,0 +1,187 @@
"use client";
import { updateCommissionCustomCard } from "@/actions/commissions/customCards/updateCard";
import type { CommissionCustomCardImageItem } from "@/actions/commissions/customCards/images";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker";
type CustomCardOption = {
optionId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type CustomCardExtra = {
extraId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type CustomCardWithItems = {
id: string;
name: string;
description: string | null;
referenceImageUrl: string | null;
isVisible: boolean;
isSpecialOffer: boolean;
options: CustomCardOption[];
extras: CustomCardExtra[];
};
type Props = {
card: CustomCardWithItems;
allOptions: CommissionOption[];
allExtras: CommissionExtra[];
images: CommissionCustomCardImageItem[];
};
export default function EditCustomCardForm({
card,
allOptions,
allExtras,
images,
}: Props) {
const router = useRouter();
const form = useForm<CommissionCustomCardValues>({
resolver: zodResolver(commissionCustomCardSchema),
defaultValues: {
name: card.name,
description: card.description ?? "",
isVisible: card.isVisible,
isSpecialOffer: card.isSpecialOffer,
referenceImageUrl: card.referenceImageUrl ?? null,
options: card.options.map((o) => ({
optionId: o.optionId,
price: o.price ?? undefined,
pricePercent: o.pricePercent ?? undefined,
priceRange: o.priceRange ?? undefined,
})),
extras: card.extras.map((e) => ({
extraId: e.extraId,
price: e.price ?? undefined,
pricePercent: e.pricePercent ?? undefined,
priceRange: e.priceRange ?? undefined,
})),
},
});
async function onSubmit(values: CommissionCustomCardValues) {
try {
await updateCommissionCustomCard(card.id, values);
toast.success("Custom commission card updated.");
router.push("/commissions/custom-cards");
} catch (err) {
console.error(err);
toast("Failed to update custom commission card.");
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>The name of the custom commission card.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible on app</FormLabel>
<FormDescription>Controls whether the card is shown.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSpecialOffer"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Special offer</FormLabel>
<FormDescription>Adds a special offer badge on the app.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceImageUrl"
render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/>
<CommissionOptionField options={allOptions} />
<CommissionExtraField extras={allExtras} />
<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>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import { deleteCommissionCustomCard } from "@/actions/commissions/customCards/deleteCard";
import { updateCommissionCustomCardSortOrder } from "@/actions/commissions/customCards/updateSortOrder";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import { PencilIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import SortableItemCard from "../types/SortableItemCard";
type CustomCardOption = {
id: string;
optionId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
option: { name: string } | null;
};
type CustomCardExtra = {
id: string;
extraId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
extra: { name: string } | null;
};
export type CommissionCustomCardWithItems = {
id: string;
name: string;
description: string | null;
referenceImageUrl: string | null;
isVisible: boolean;
isSpecialOffer: boolean;
options: CustomCardOption[];
extras: CustomCardExtra[];
};
export default function ListCustomCards({
cards,
}: {
cards: CommissionCustomCardWithItems[];
}) {
const [items, setItems] = useState(cards);
const [isMounted, setIsMounted] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
setIsMounted(true);
}, []);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
await updateCommissionCustomCardSortOrder(
newItems.map((item, i) => ({ id: item.id, sortIndex: i }))
);
}
};
const confirmDelete = () => {
if (!deleteTargetId) return;
startTransition(async () => {
await deleteCommissionCustomCard(deleteTargetId);
setItems((prev) => prev.filter((i) => i.id !== deleteTargetId));
setDialogOpen(false);
setDeleteTargetId(null);
});
};
if (!isMounted) return null;
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map((card) => (
<SortableItemCard key={card.id} id={card.id}>
<Card>
<CardHeader className="relative">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl truncate">{card.name}</CardTitle>
{!card.isVisible ? (
<Badge variant="secondary">Hidden</Badge>
) : (
<Badge variant="outline">Visible</Badge>
)}
{card.isSpecialOffer ? (
<Badge className="bg-amber-500 text-amber-950 hover:bg-amber-500">
Special
</Badge>
) : null}
</div>
<CardDescription>{card.description}</CardDescription>
{card.referenceImageUrl ? (
<p className="text-xs text-muted-foreground">Has image</p>
) : null}
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{card.options.map((opt) => (
<li key={opt.id}>
{opt.option?.name}:{" "}
{opt.price !== null
? `${opt.price}`
: opt.pricePercent
? `+${opt.pricePercent}%`
: opt.priceRange
? `${opt.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{card.extras.map((ext) => (
<li key={ext.id}>
{ext.extra?.name}:{" "}
{ext.price !== null
? `${ext.price}`
: ext.pricePercent
? `+${ext.pricePercent}%`
: ext.priceRange
? `${ext.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Link href={`/commissions/custom-cards/${card.id}`} className="w-full">
<Button variant="default" className="w-full flex items-center gap-2">
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="destructive"
className="w-full flex items-center gap-2"
onClick={() => {
setDeleteTargetId(card.id);
setDialogOpen(true);
}}
>
<TrashIcon className="h-4 w-4" />
Delete
</Button>
</CardFooter>
</Card>
</SortableItemCard>
))}
</SortableContext>
</DndContext>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this custom card?</DialogTitle>
</DialogHeader>
<p>This action cannot be undone. Are you sure you want to continue?</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" disabled={isPending} onClick={confirmDelete}>
Confirm Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import { createCommissionCustomCard } from "@/actions/commissions/customCards/newCard";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker";
import type { CommissionCustomCardImageItem } from "@/actions/commissions/customCards/images";
type Props = {
options: CommissionOption[];
extras: CommissionExtra[];
images: CommissionCustomCardImageItem[];
};
export default function NewCustomCardForm({ options, extras, images }: Props) {
const router = useRouter();
const form = useForm<CommissionCustomCardValues>({
resolver: zodResolver(commissionCustomCardSchema),
defaultValues: {
name: "",
description: "",
isVisible: true,
isSpecialOffer: false,
referenceImageUrl: null,
options: [],
extras: [],
},
});
async function onSubmit(values: CommissionCustomCardValues) {
try {
const created = await createCommissionCustomCard(values);
console.log("Commission custom card created:", created);
toast("Custom commission card created.");
router.push("/commissions/custom-cards");
} catch (err) {
console.error(err);
toast("Failed to create custom commission card.");
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>The name of the custom commission card.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible on app</FormLabel>
<FormDescription>Controls whether the card is shown.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSpecialOffer"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Special offer</FormLabel>
<FormDescription>Adds a special offer badge on the app.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceImageUrl"
render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/>
<CommissionOptionField options={options} />
<CommissionExtraField extras={extras} />
<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>
);
}

View File

@ -2,16 +2,20 @@
import type { Value } from 'platejs';
import { deleteCommissionExample, uploadCommissionExample } from "@/actions/commissions/examples";
import { saveGuidelines } from '@/actions/commissions/guidelines/saveGuidelines';
import { BasicBlocksKit } from '@/components/editor/plugins/basic-blocks-kit';
import { BasicMarksKit } from '@/components/editor/plugins/basic-marks-kit';
import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit';
import { ListKit } from '@/components/editor/plugins/list-kit';
import { MarkdownKit } from '@/components/editor/plugins/markdown-kit';
import { Button } from "@/components/ui/button";
import { Editor, EditorContainer } from '@/components/ui/editor';
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
import { Label } from '@/components/ui/label';
import { BulletedListToolbarButton, NumberedListToolbarButton } from '@/components/ui/list-toolbar-button';
import { MarkToolbarButton } from '@/components/ui/mark-toolbar-button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToolbarButton } from '@/components/ui/toolbar';
import {
Bold,
@ -27,14 +31,25 @@ import {
Underline
} from "lucide-react";
import { Plate, usePlateEditor } from 'platejs/react';
import { useEffect } from 'react';
import { useEffect, useMemo, useState, useTransition } from 'react';
const initialValue: Value = [
];
export default function GuidelinesEditor({ markdown }: { markdown: string | null }) {
export default function GuidelinesEditor({
markdown,
exampleImageUrl,
examples,
}: {
markdown: string | null;
exampleImageUrl: string | null;
examples: { key: string; url: string; size: number | null; lastModified: string | null }[];
}) {
// const [isSaving, setIsSaving] = useState(false);
const [exampleItems, setExampleItems] = useState(examples);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const editor = usePlateEditor({
plugins: [
...BasicBlocksKit,
@ -54,17 +69,104 @@ export default function GuidelinesEditor({ markdown }: { markdown: string | null
}
}, [editor, markdown]);
useEffect(() => {
const match = exampleItems.find((item) => item.url === exampleImageUrl);
setSelectedKey(match?.key ?? null);
}, [exampleImageUrl, exampleItems]);
const selectedUrl = useMemo(() => {
if (!selectedKey) return "";
return exampleItems.find((item) => item.key === selectedKey)?.url ?? "";
}, [exampleItems, selectedKey]);
const handleSave = async () => {
// console.log(editor);
if (!editor.api.markdown.serialize) return;
// setIsSaving(true);
const markdown = editor.api.markdown.serialize();
await saveGuidelines(markdown);
await saveGuidelines(markdown, selectedUrl || null);
// setIsSaving(false);
};
const handleUpload = (file: File) => {
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const item = await uploadCommissionExample(fd);
setExampleItems((prev) => [item, ...prev]);
setSelectedKey(item.key);
});
};
const handleDelete = () => {
if (!selectedKey) return;
if (!window.confirm("Delete this example image from S3?")) return;
startTransition(async () => {
await deleteCommissionExample(selectedKey);
setExampleItems((prev) => prev.filter((item) => item.key !== selectedKey));
setSelectedKey(null);
});
};
return (
<Plate editor={editor}> {/* Provides editor context */}
<div className="px-4 pt-4 flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label className="text-sm font-medium">Example image</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Select
value={selectedKey ?? undefined}
onValueChange={(value) =>
setSelectedKey(value === "__none__" ? null : value)
}
>
<SelectTrigger className="w-full sm:max-w-md">
<SelectValue placeholder="Select an example image" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{exampleItems.map((item) => (
<SelectItem key={item.key} value={item.key}>
{item.key.replace("commissions/examples/", "")}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedUrl ? (
<a
href={selectedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
Open
</a>
) : null}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.currentTarget.value = "";
}}
/>
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={!selectedKey || isPending}
>
Delete selected
</Button>
</div>
</div>
<FixedToolbar className="justify-start rounded-t-lg">
{/* Blocks */}
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">

View File

@ -34,6 +34,10 @@ const commissionItems = [
title: "Types",
href: "/commissions/types",
},
{
title: "Custom Cards",
href: "/commissions/custom-cards",
},
{
title: "Guidelines",
href: "/commissions/guidelines",

View File

@ -45,6 +45,7 @@ export const adminNav: AdminNavGroup[] = [
{ title: "Requests", href: "/commissions/requests" },
{ title: "Board", href: "/commissions/kanban" },
{ title: "Types", href: "/commissions/types" },
{ title: "Custom Cards", href: "/commissions/custom-cards" },
{ title: "TypeOptions", href: "/commissions/types/options" },
{ title: "TypeExtras", href: "/commissions/types/extras" },
{ title: "Guidelines", href: "/commissions/guidelines" },

View File

@ -0,0 +1,35 @@
import * as z from "zod/v4";
const rangePattern = /^\d{1,3}\d{1,3}$/;
const optionField = z.object({
optionId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
const extraField = z.object({
extraId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
export const commissionCustomCardSchema = z.object({
name: z.string().min(1, "Name is required. Min 1 character."),
description: z.string().optional(),
isVisible: z.boolean().default(true),
isSpecialOffer: z.boolean().default(false),
referenceImageUrl: z.string().nullable().optional(),
options: z.array(optionField).optional(),
extras: z.array(extraField).optional(),
});
export type CommissionCustomCardValues = z.infer<typeof commissionCustomCardSchema>;