Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2015ea6f2e
|
|||
|
e869f19142
|
|||
|
51cfde4d78
|
|||
|
88bb301e84
|
@ -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"]
|
||||
|
||||
2
prisma/migrations/20260131151654_com_6/migration.sql
Normal file
2
prisma/migrations/20260131151654_com_6/migration.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CommissionGuidelines" ADD COLUMN "exampleImageUrl" TEXT;
|
||||
65
prisma/migrations/20260201142100_com_7/migration.sql
Normal file
65
prisma/migrations/20260201142100_com_7/migration.sql
Normal 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;
|
||||
5
prisma/migrations/20260201144904_com_8/migration.sql
Normal file
5
prisma/migrations/20260201144904_com_8/migration.sql
Normal 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;
|
||||
@ -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])
|
||||
|
||||
130
src/actions/artworks/generateGalleryVariant.ts
Normal file
130
src/actions/artworks/generateGalleryVariant.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
24
src/actions/artworks/getGalleryVariantStats.ts
Normal file
24
src/actions/artworks/getGalleryVariantStats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
src/actions/commissions/customCards/deleteCard.ts
Normal file
9
src/actions/commissions/customCards/deleteCard.ts
Normal file
@ -0,0 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function deleteCommissionCustomCard(id: string) {
|
||||
await prisma.commissionCustomCard.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
94
src/actions/commissions/customCards/images.ts
Normal file
94
src/actions/commissions/customCards/images.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
52
src/actions/commissions/customCards/newCard.ts
Normal file
52
src/actions/commissions/customCards/newCard.ts
Normal 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;
|
||||
}
|
||||
51
src/actions/commissions/customCards/updateCard.ts
Normal file
51
src/actions/commissions/customCards/updateCard.ts
Normal 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;
|
||||
}
|
||||
16
src/actions/commissions/customCards/updateSortOrder.ts
Normal file
16
src/actions/commissions/customCards/updateSortOrder.ts
Normal 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 },
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
92
src/actions/commissions/examples.ts
Normal file
92
src/actions/commissions/examples.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
43
src/app/(admin)/commissions/custom-cards/[id]/page.tsx
Normal file
43
src/app/(admin)/commissions/custom-cards/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/(admin)/commissions/custom-cards/new/page.tsx
Normal file
20
src/app/(admin)/commissions/custom-cards/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/(admin)/commissions/custom-cards/page.tsx
Normal file
34
src/app/(admin)/commissions/custom-cards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
62
src/components/artworks/ArtworkGalleryVariantProcessor.tsx
Normal file
62
src/components/artworks/ArtworkGalleryVariantProcessor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
116
src/components/commissions/customCards/CustomCardImagePicker.tsx
Normal file
116
src/components/commissions/customCards/CustomCardImagePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/components/commissions/customCards/EditCustomCardForm.tsx
Normal file
187
src/components/commissions/customCards/EditCustomCardForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/components/commissions/customCards/ListCustomCards.tsx
Normal file
219
src/components/commissions/customCards/ListCustomCards.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
src/components/commissions/customCards/NewCustomCardForm.tsx
Normal file
147
src/components/commissions/customCards/NewCustomCardForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -34,6 +34,10 @@ const commissionItems = [
|
||||
title: "Types",
|
||||
href: "/commissions/types",
|
||||
},
|
||||
{
|
||||
title: "Custom Cards",
|
||||
href: "/commissions/custom-cards",
|
||||
},
|
||||
{
|
||||
title: "Guidelines",
|
||||
href: "/commissions/guidelines",
|
||||
|
||||
@ -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" },
|
||||
|
||||
35
src/schemas/commissionCustomCard.ts
Normal file
35
src/schemas/commissionCustomCard.ts
Normal 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 '10–80'")
|
||||
.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 '10–80'")
|
||||
.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>;
|
||||
Reference in New Issue
Block a user