Compare commits
4 Commits
timelapse
...
ai-alt-tex
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb4391d1d7
|
|||
|
e869f19142
|
|||
|
51cfde4d78
|
|||
|
88bb301e84
|
@ -45,3 +45,12 @@ USER nextjs
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "./server.js"]
|
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;
|
||||||
@ -399,6 +399,7 @@ model CommissionGuidelines {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
markdown String
|
markdown String
|
||||||
|
exampleImageUrl String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
|
|||||||
86
src/actions/artworks/generateAltText.ts
Normal file
86
src/actions/artworks/generateAltText.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
|
||||||
|
|
||||||
|
export async function generateAltTextForArtwork(
|
||||||
|
artworkId: string,
|
||||||
|
prompt?: string,
|
||||||
|
) {
|
||||||
|
const serviceUrl = process.env.ALT_TEXT_SERVICE_URL;
|
||||||
|
if (!serviceUrl) {
|
||||||
|
throw new Error("ALT_TEXT_SERVICE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const artwork = await prisma.artwork.findUnique({
|
||||||
|
where: { id: artworkId },
|
||||||
|
select: {
|
||||||
|
variants: {
|
||||||
|
where: { type: "original" },
|
||||||
|
select: { s3Key: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = artwork?.variants?.[0];
|
||||||
|
if (!original?.s3Key) {
|
||||||
|
throw new Error("Original image variant not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await getImageBufferFromS3Key(original.s3Key);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
formData.append(
|
||||||
|
"image",
|
||||||
|
new Blob([bytes], { type: "image/jpeg" }),
|
||||||
|
"artwork.jpg",
|
||||||
|
);
|
||||||
|
if (prompt && prompt.trim().length > 0) {
|
||||||
|
formData.append("prompt", prompt.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
response = await fetch(`${serviceUrl}/caption`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt === 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error(`Alt text service failed: ${String(lastError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Alt text service failed: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { altText?: string; error?: string };
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Alt text service error: ${data.error}`);
|
||||||
|
}
|
||||||
|
if (!data.altText) {
|
||||||
|
throw new Error(`Alt text service returned no result: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.altText.trim();
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
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";
|
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({
|
const guidelines = await prisma.commissionGuidelines.findFirst({
|
||||||
where: { isActive: true },
|
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";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export async function saveGuidelines(markdown: string) {
|
export async function saveGuidelines(markdown: string, exampleImageUrl: string | null) {
|
||||||
await prisma.commissionGuidelines.updateMany({
|
await prisma.commissionGuidelines.updateMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
data: { isActive: false },
|
data: { isActive: false },
|
||||||
@ -11,6 +11,7 @@ export async function saveGuidelines(markdown: string) {
|
|||||||
await prisma.commissionGuidelines.create({
|
await prisma.commissionGuidelines.create({
|
||||||
data: {
|
data: {
|
||||||
markdown,
|
markdown,
|
||||||
|
exampleImageUrl: exampleImageUrl || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import sharp from "sharp";
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
|
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)) {
|
if (!(imageFile instanceof File)) {
|
||||||
console.log("No image or invalid type");
|
console.log("No image or invalid type");
|
||||||
return null;
|
return null;
|
||||||
@ -29,6 +32,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
const modifiedKey = `modified/${fileKey}.webp`;
|
const modifiedKey = `modified/${fileKey}.webp`;
|
||||||
const resizedKey = `resized/${fileKey}.webp`;
|
const resizedKey = `resized/${fileKey}.webp`;
|
||||||
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
const thumbnailKey = `thumbnail/${fileKey}.webp`;
|
||||||
|
const galleryKey = `gallery/${fileKey}.webp`;
|
||||||
|
|
||||||
const sharpData = sharp(buffer);
|
const sharpData = sharp(buffer);
|
||||||
const metadata = await sharpData.metadata();
|
const metadata = await sharpData.metadata();
|
||||||
@ -40,7 +44,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: originalKey,
|
Key: originalKey,
|
||||||
Body: buffer,
|
Body: buffer,
|
||||||
ContentType: "image/" + metadata.format,
|
ContentType: "image/" + metadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Modified file
|
//--- Modified file
|
||||||
@ -53,7 +57,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: modifiedKey,
|
Key: modifiedKey,
|
||||||
Body: modifiedBuffer,
|
Body: modifiedBuffer,
|
||||||
ContentType: "image/" + modifiedMetadata.format,
|
ContentType: "image/" + modifiedMetadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Resized file
|
//--- Resized file
|
||||||
@ -62,7 +66,8 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
|
|
||||||
let resizeOptions: { width?: number; height?: number };
|
let resizeOptions: { width?: number; height?: number };
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
resizeOptions = height < width ? { height: targetSize } : { width: targetSize };
|
resizeOptions =
|
||||||
|
height < width ? { height: targetSize } : { width: targetSize };
|
||||||
} else {
|
} else {
|
||||||
resizeOptions = { height: targetSize };
|
resizeOptions = { height: targetSize };
|
||||||
}
|
}
|
||||||
@ -80,7 +85,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: resizedKey,
|
Key: resizedKey,
|
||||||
Body: resizedBuffer,
|
Body: resizedBuffer,
|
||||||
ContentType: "image/" + resizedMetadata.format,
|
ContentType: "image/" + resizedMetadata.format,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
//--- Thumbnail file
|
//--- Thumbnail file
|
||||||
@ -88,7 +93,10 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
|
|
||||||
let thumbnailOptions: { width?: number; height?: number };
|
let thumbnailOptions: { width?: number; height?: number };
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
thumbnailOptions = height < width ? { height: thumbnailTargetSize } : { width: thumbnailTargetSize };
|
thumbnailOptions =
|
||||||
|
height < width
|
||||||
|
? { height: thumbnailTargetSize }
|
||||||
|
: { width: thumbnailTargetSize };
|
||||||
} else {
|
} else {
|
||||||
thumbnailOptions = { height: thumbnailTargetSize };
|
thumbnailOptions = { height: thumbnailTargetSize };
|
||||||
}
|
}
|
||||||
@ -106,7 +114,36 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
Key: thumbnailKey,
|
Key: thumbnailKey,
|
||||||
Body: thumbnailBuffer,
|
Body: thumbnailBuffer,
|
||||||
ContentType: "image/" + thumbnailMetadata.format,
|
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({
|
const fileRecord = await prisma.fileData.create({
|
||||||
@ -193,6 +230,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
|
|||||||
sizeBytes: thumbnailMetadata.size,
|
sizeBytes: thumbnailMetadata.size,
|
||||||
artworkId: artworkRecord.id,
|
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)
|
// (nothing else to do here)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return artworkRecord;
|
return artworkRecord;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
|
|||||||
{item && <DeleteArtworkButton artworkId={item.id} />}
|
{item && <DeleteArtworkButton artworkId={item.id} />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{item && <ArtworkVariants variants={item.variants} />}
|
{item && <ArtworkVariants artworkId={item.id} variants={item.variants} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -45,4 +45,4 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
|
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
|
||||||
|
import { ArtworkGalleryVariantProcessor } from "@/components/artworks/ArtworkGalleryVariantProcessor";
|
||||||
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
||||||
import { getArtworksPage } from "@/lib/queryArtworks";
|
import { getArtworksPage } from "@/lib/queryArtworks";
|
||||||
|
|
||||||
@ -59,6 +60,7 @@ export default async function ArtworksPage({
|
|||||||
<h1 className="text-2xl font-bold">Artworks</h1>
|
<h1 className="text-2xl font-bold">Artworks</h1>
|
||||||
{/* <ProcessArtworkColorsButton /> */}
|
{/* <ProcessArtworkColorsButton /> */}
|
||||||
<ArtworkColorProcessor />
|
<ArtworkColorProcessor />
|
||||||
|
<ArtworkGalleryVariantProcessor />
|
||||||
<ArtworksTable />
|
<ArtworksTable />
|
||||||
</div>
|
</div>
|
||||||
// <div>
|
// <div>
|
||||||
@ -89,4 +91,4 @@ export default async function ArtworksPage({
|
|||||||
// </div>
|
// </div>
|
||||||
// </div >
|
// </div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
|
import { listCommissionExamples } from "@/actions/commissions/examples";
|
||||||
import { getActiveGuidelines } from "@/actions/commissions/guidelines/getGuidelines";
|
import { getActiveGuidelines } from "@/actions/commissions/guidelines/getGuidelines";
|
||||||
import GuidelinesEditor from "@/components/commissions/guidelines/Editor";
|
import GuidelinesEditor from "@/components/commissions/guidelines/Editor";
|
||||||
|
|
||||||
export default async function CommissionGuidelinesPage() {
|
export default async function CommissionGuidelinesPage() {
|
||||||
const markdown = await getActiveGuidelines();
|
const [{ markdown, exampleImageUrl }, examples] = await Promise.all([
|
||||||
|
getActiveGuidelines(),
|
||||||
|
listCommissionExamples(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -10,8 +14,12 @@ export default async function CommissionGuidelinesPage() {
|
|||||||
<h1 className="text-2xl font-bold mb-4">Commission Guidelines</h1>
|
<h1 className="text-2xl font-bold mb-4">Commission Guidelines</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4 p-1 border rounded-xl bg-muted/20">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { formatFileSize } from "@/utils/formatFileSize";
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
|
|
||||||
const ORDER: Record<string, number> = {
|
const ORDER: Record<string, number> = {
|
||||||
thumbnail: 0,
|
thumbnail: 0,
|
||||||
resized: 1,
|
gallery: 1,
|
||||||
modified: 2,
|
resized: 2,
|
||||||
original: 3,
|
modified: 3,
|
||||||
|
original: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
function byVariantOrder(a: FileVariant, b: FileVariant) {
|
function byVariantOrder(a: FileVariant, b: FileVariant) {
|
||||||
@ -16,22 +23,58 @@ function byVariantOrder(a: FileVariant, b: FileVariant) {
|
|||||||
return a.type.localeCompare(b.type);
|
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);
|
const sorted = [...variants].sort(byVariantOrder);
|
||||||
|
|
||||||
return (
|
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>
|
<div>
|
||||||
{sorted.map((variant) => (
|
{sorted.map((variant) => (
|
||||||
<div key={variant.id}>
|
<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 && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,50 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
|
import { generateAltTextForArtwork } from "@/actions/artworks/generateAltText";
|
||||||
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import MultipleSelector from "@/components/ui/multiselect";
|
import MultipleSelector from "@/components/ui/multiselect";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtTag } from "@/generated/prisma/client";
|
import type { ArtTag } from "@/generated/prisma/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
export default function EditArtworkForm({ artwork, categories, tags }:
|
export default function EditArtworkForm({
|
||||||
{
|
artwork,
|
||||||
artwork: ArtworkWithRelations,
|
categories,
|
||||||
categories: CategoryWithTags[]
|
tags,
|
||||||
tags: ArtTag[]
|
}: {
|
||||||
}) {
|
artwork: ArtworkWithRelations;
|
||||||
|
categories: CategoryWithTags[];
|
||||||
|
tags: ArtTag[];
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isGeneratingAlt, startAltTransition] = useTransition();
|
||||||
const form = useForm<z.infer<typeof artworkSchema>>({
|
const form = useForm<z.infer<typeof artworkSchema>>({
|
||||||
resolver: zodResolver(artworkSchema),
|
resolver: zodResolver(artworkSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -41,19 +59,21 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
notes: artwork.notes || "",
|
notes: artwork.notes || "",
|
||||||
month: artwork.month || undefined,
|
month: artwork.month || undefined,
|
||||||
year: artwork.year || undefined,
|
year: artwork.year || undefined,
|
||||||
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
|
creationDate: artwork.creationDate
|
||||||
categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
|
? new Date(artwork.creationDate)
|
||||||
tagIds: artwork.tags?.map(tag => tag.id) ?? [],
|
: undefined,
|
||||||
|
categoryIds: artwork.categories?.map((cat) => cat.id) ?? [],
|
||||||
|
tagIds: artwork.tags?.map((tag) => tag.id) ?? [],
|
||||||
newCategoryNames: [],
|
newCategoryNames: [],
|
||||||
newTagNames: []
|
newTagNames: [],
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
||||||
const updatedArtwork = await updateArtwork(values, artwork.id)
|
const updatedArtwork = await updateArtwork(values, artwork.id);
|
||||||
if (updatedArtwork) {
|
if (updatedArtwork) {
|
||||||
toast.success("Artwork updated")
|
toast.success("Artwork updated");
|
||||||
router.push(`/artworks`)
|
router.push(`/artworks`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,10 +100,42 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
name="altText"
|
name="altText"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Alt Text</FormLabel>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<FormLabel>Alt Text</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isGeneratingAlt}
|
||||||
|
onClick={() =>
|
||||||
|
startAltTransition(async () => {
|
||||||
|
try {
|
||||||
|
const alt = await generateAltTextForArtwork(
|
||||||
|
artwork.id,
|
||||||
|
field.value,
|
||||||
|
);
|
||||||
|
form.setValue("altText", alt, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
toast.success("Alt text generated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Alt text generation failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isGeneratingAlt ? "Generating..." : "Generate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Alt for this image" />
|
<Input {...field} placeholder="Alt for this image" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Generates a caption from the original image. CPU-only can take
|
||||||
|
10–30s.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea {...field} placeholder="A descriptive text to the image" />
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="A descriptive text to the image"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -125,9 +180,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
field.onChange(
|
||||||
|
e.target.value === "" ? undefined : +e.target.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -145,9 +202,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
field.onChange(
|
||||||
|
e.target.value === "" ? undefined : +e.target.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -170,10 +229,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pl-3 text-left font-normal",
|
"pl-3 text-left font-normal",
|
||||||
!field.value && "text-muted-foreground"
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
{field.value
|
||||||
|
? format(field.value, "PPP")
|
||||||
|
: "Pick a date"}
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@ -182,7 +243,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={field.value}
|
selected={field.value}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
field.onChange(date)
|
field.onChange(date);
|
||||||
}}
|
}}
|
||||||
initialFocus
|
initialFocus
|
||||||
fromYear={1990}
|
fromYear={1990}
|
||||||
@ -236,17 +297,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
const existingIds = values.filter(
|
||||||
|
(v) => !v.startsWith("__new__:"),
|
||||||
|
);
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
|
form.setValue(
|
||||||
shouldDirty: true,
|
"newCategoryNames",
|
||||||
shouldValidate: true,
|
Array.from(new Set(newNames)),
|
||||||
});
|
{
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -276,7 +343,8 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
.map((t) => {
|
.map((t) => {
|
||||||
let group = "Other tags";
|
let group = "Other tags";
|
||||||
if (selectedTagIds.includes(t.id)) group = "Selected";
|
if (selectedTagIds.includes(t.id)) group = "Selected";
|
||||||
else if (preferredTagIds.has(t.id)) group = "From selected categories";
|
else if (preferredTagIds.has(t.id))
|
||||||
|
group = "From selected categories";
|
||||||
|
|
||||||
return { label: t.name, value: t.id, group };
|
return { label: t.name, value: t.id, group };
|
||||||
})
|
})
|
||||||
@ -301,12 +369,19 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<MultipleSelector
|
<MultipleSelector
|
||||||
options={tagOptions}
|
options={tagOptions}
|
||||||
groupBy="group"
|
groupBy="group"
|
||||||
groupOrder={["Selected", "From selected categories", "Other tags"]}
|
groupOrder={[
|
||||||
|
"Selected",
|
||||||
|
"From selected categories",
|
||||||
|
"Other tags",
|
||||||
|
]}
|
||||||
showSelectedInDropdown
|
showSelectedInDropdown
|
||||||
placeholder="Select or type to create tags"
|
placeholder="Select or type to create tags"
|
||||||
hidePlaceholderWhenSelected
|
hidePlaceholderWhenSelected
|
||||||
selectFirstItem
|
selectFirstItem
|
||||||
value={[...selectedExistingOptions, ...selectedNewOptions]}
|
value={[
|
||||||
|
...selectedExistingOptions,
|
||||||
|
...selectedNewOptions,
|
||||||
|
]}
|
||||||
creatable
|
creatable
|
||||||
createOption={(raw) => ({
|
createOption={(raw) => ({
|
||||||
value: `__new__:${raw}`,
|
value: `__new__:${raw}`,
|
||||||
@ -316,17 +391,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
const existingIds = values.filter(
|
||||||
|
(v) => !v.startsWith("__new__:"),
|
||||||
|
);
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue("newTagNames", Array.from(new Set(newNames)), {
|
form.setValue(
|
||||||
shouldDirty: true,
|
"newTagNames",
|
||||||
shouldValidate: true,
|
Array.from(new Set(newNames)),
|
||||||
});
|
{
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -347,7 +428,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormDescription></FormDescription>
|
<FormDescription></FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -359,10 +443,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>NSFW</FormLabel>
|
<FormLabel>NSFW</FormLabel>
|
||||||
<FormDescription>This image contains sensitive or adult content.</FormDescription>
|
<FormDescription>
|
||||||
|
This image contains sensitive or adult content.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -374,10 +463,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Publish</FormLabel>
|
<FormLabel>Publish</FormLabel>
|
||||||
<FormDescription>Will this image be published.</FormDescription>
|
<FormDescription>
|
||||||
|
Will this image be published.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -389,10 +483,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Set as header image</FormLabel>
|
<FormLabel>Set as header image</FormLabel>
|
||||||
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
|
<FormDescription>
|
||||||
|
Will be the main banner image. Choose a fitting one.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -444,10 +543,16 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
/> */}
|
/> */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
<Button
|
||||||
|
type="reset"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,20 @@
|
|||||||
|
|
||||||
import type { Value } from 'platejs';
|
import type { Value } from 'platejs';
|
||||||
|
|
||||||
|
import { deleteCommissionExample, uploadCommissionExample } from "@/actions/commissions/examples";
|
||||||
import { saveGuidelines } from '@/actions/commissions/guidelines/saveGuidelines';
|
import { saveGuidelines } from '@/actions/commissions/guidelines/saveGuidelines';
|
||||||
import { BasicBlocksKit } from '@/components/editor/plugins/basic-blocks-kit';
|
import { BasicBlocksKit } from '@/components/editor/plugins/basic-blocks-kit';
|
||||||
import { BasicMarksKit } from '@/components/editor/plugins/basic-marks-kit';
|
import { BasicMarksKit } from '@/components/editor/plugins/basic-marks-kit';
|
||||||
import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit';
|
import { CodeBlockKit } from '@/components/editor/plugins/code-block-kit';
|
||||||
import { ListKit } from '@/components/editor/plugins/list-kit';
|
import { ListKit } from '@/components/editor/plugins/list-kit';
|
||||||
import { MarkdownKit } from '@/components/editor/plugins/markdown-kit';
|
import { MarkdownKit } from '@/components/editor/plugins/markdown-kit';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Editor, EditorContainer } from '@/components/ui/editor';
|
import { Editor, EditorContainer } from '@/components/ui/editor';
|
||||||
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
|
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { BulletedListToolbarButton, NumberedListToolbarButton } from '@/components/ui/list-toolbar-button';
|
import { BulletedListToolbarButton, NumberedListToolbarButton } from '@/components/ui/list-toolbar-button';
|
||||||
import { MarkToolbarButton } from '@/components/ui/mark-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 { ToolbarButton } from '@/components/ui/toolbar';
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@ -27,14 +31,25 @@ import {
|
|||||||
Underline
|
Underline
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Plate, usePlateEditor } from 'platejs/react';
|
import { Plate, usePlateEditor } from 'platejs/react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
const initialValue: Value = [
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [exampleItems, setExampleItems] = useState(examples);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
const editor = usePlateEditor({
|
const editor = usePlateEditor({
|
||||||
plugins: [
|
plugins: [
|
||||||
...BasicBlocksKit,
|
...BasicBlocksKit,
|
||||||
@ -54,17 +69,104 @@ export default function GuidelinesEditor({ markdown }: { markdown: string | null
|
|||||||
}
|
}
|
||||||
}, [editor, markdown]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
// console.log(editor);
|
// console.log(editor);
|
||||||
if (!editor.api.markdown.serialize) return;
|
if (!editor.api.markdown.serialize) return;
|
||||||
// setIsSaving(true);
|
// setIsSaving(true);
|
||||||
const markdown = editor.api.markdown.serialize();
|
const markdown = editor.api.markdown.serialize();
|
||||||
await saveGuidelines(markdown);
|
await saveGuidelines(markdown, selectedUrl || null);
|
||||||
// setIsSaving(false);
|
// 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 (
|
return (
|
||||||
<Plate editor={editor}> {/* Provides editor context */}
|
<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">
|
<FixedToolbar className="justify-start rounded-t-lg">
|
||||||
{/* Blocks */}
|
{/* Blocks */}
|
||||||
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">
|
<ToolbarButton onClick={() => editor.tf.h1.toggle()} tooltip="Heading 1">
|
||||||
@ -110,4 +212,4 @@ export default function GuidelinesEditor({ markdown }: { markdown: string | null
|
|||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
</Plate>
|
</Plate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user