5 Commits

Author SHA1 Message Date
eb4391d1d7 Add local AI vition option for alt text 2026-02-01 15:07:08 +01:00
e869f19142 Add commission type example image 2026-01-31 16:37:24 +01:00
51cfde4d78 Fix docker file for prod 2026-01-31 10:48:37 +01:00
88bb301e84 Add new gallery variant 2026-01-31 01:34:13 +01:00
3ce4c9acfc Add timelapse upload and display to image edit page 2026-01-30 22:18:24 +01:00
23 changed files with 1180 additions and 88 deletions

View File

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

View File

@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "ArtworkTimelapse" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"artworkId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"s3Key" TEXT NOT NULL,
"fileName" TEXT,
"mimeType" TEXT,
"sizeBytes" INTEGER,
CONSTRAINT "ArtworkTimelapse_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTimelapse_artworkId_key" ON "ArtworkTimelapse"("artworkId");
-- CreateIndex
CREATE UNIQUE INDEX "ArtworkTimelapse_s3Key_key" ON "ArtworkTimelapse"("s3Key");
-- AddForeignKey
ALTER TABLE "ArtworkTimelapse" ADD CONSTRAINT "ArtworkTimelapse_artworkId_fkey" FOREIGN KEY ("artworkId") REFERENCES "Artwork"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@ -48,6 +48,7 @@ model Artwork {
gallery Gallery? @relation(fields: [galleryId], references: [id])
metadata ArtworkMetadata?
timelapse ArtworkTimelapse?
albums Album[]
categories ArtCategory[]
@ -195,6 +196,22 @@ model ArtworkMetadata {
artwork Artwork @relation(fields: [artworkId], references: [id])
}
model ArtworkTimelapse {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
artworkId String @unique
artwork Artwork @relation(fields: [artworkId], references: [id], onDelete: Cascade)
enabled Boolean @default(false)
s3Key String @unique
fileName String?
mimeType String?
sizeBytes Int?
}
model FileData {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -382,6 +399,7 @@ model CommissionGuidelines {
updatedAt DateTime @updatedAt
markdown String
exampleImageUrl String?
isActive Boolean @default(true)
@@index([isActive])

View File

@ -9,6 +9,7 @@ export async function deleteArtwork(artworkId: string) {
where: { id: artworkId },
include: {
variants: true,
timelapse: true,
colors: true,
metadata: true,
tags: true,
@ -32,6 +33,20 @@ export async function deleteArtwork(artworkId: string) {
}
}
// Delete timelapse S3 object (if present)
if (artwork.timelapse?.s3Key) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: artwork.timelapse.s3Key,
})
);
} catch (err) {
console.warn(`Failed to delete timelapse S3 object: ${artwork.timelapse.s3Key}. ${err}`);
}
}
// Step 1: Delete join entries
await prisma.artworkColor.deleteMany({ where: { artworkId } });
@ -48,6 +63,9 @@ export async function deleteArtwork(artworkId: string) {
// Delete variants
await prisma.fileVariant.deleteMany({ where: { artworkId } });
// Delete timelapse DB row (relation also cascades, but be explicit)
await prisma.artworkTimelapse.deleteMany({ where: { artworkId } });
// Delete metadata
await prisma.artworkMetadata.deleteMany({ where: { artworkId } });

View 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();
}

View File

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

View File

@ -16,7 +16,8 @@ export async function getSingleArtwork(id: string) {
colors: { include: { color: true } },
// sortContexts: true,
tags: true,
variants: true
variants: true,
timelapse: true
}
})
}

View File

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

View File

@ -0,0 +1,136 @@
"use server";
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { revalidatePath } from "next/cache";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
const createUploadSchema = z.object({
artworkId: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/**
* Creates a presigned PUT url so the client can upload large timelapse videos directly to S3
* (avoids Next.js body-size/proxy limits).
*/
export async function createArtworkTimelapseUpload(input: z.infer<typeof createUploadSchema>) {
const { artworkId, fileName, mimeType, sizeBytes } = createUploadSchema.parse(input);
const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined;
const suffix = ext ? `.${ext}` : "";
// Keep previous uploads unique so caching/CDNs won't bite you.
const fileId = uuidv4();
const s3Key = `timelapse/${artworkId}/${fileId}${suffix}`;
const cmd = new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: s3Key,
ContentType: mimeType,
// If you want size enforcement at S3 level, you'd do that via policy; presigned PUT doesn't strictly enforce.
});
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 });
return { uploadUrl, s3Key, fileName, mimeType, sizeBytes };
}
const confirmSchema = z.object({
artworkId: z.string().min(1),
s3Key: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** Persist uploaded timelapse metadata in DB (upsert by artworkId). */
export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confirmSchema>) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } = confirmSchema.parse(input);
// If an old timelapse exists, delete the old object so you don't leak storage.
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (existing?.s3Key && existing.s3Key !== s3Key) {
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
// don't fail the request if cleanup fails
console.warn("Failed to delete previous timelapse object", existing.s3Key, err);
}
}
await prisma.artworkTimelapse.upsert({
where: { artworkId },
create: {
artworkId,
s3Key,
fileName,
mimeType,
sizeBytes,
enabled: true,
},
update: {
s3Key,
fileName,
mimeType,
sizeBytes,
},
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const enabledSchema = z.object({
artworkId: z.string().min(1),
enabled: z.boolean(),
});
export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSchema>) {
const { artworkId, enabled } = enabledSchema.parse(input);
await prisma.artworkTimelapse.update({
where: { artworkId },
data: { enabled },
});
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}
const deleteSchema = z.object({
artworkId: z.string().min(1),
});
export async function deleteArtworkTimelapse(input: z.infer<typeof deleteSchema>) {
const { artworkId } = deleteSchema.parse(input);
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (!existing) return { ok: true };
try {
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: existing.s3Key,
})
);
} catch (err) {
console.warn("Failed to delete timelapse object", existing.s3Key, err);
}
await prisma.artworkTimelapse.delete({ where: { artworkId } });
revalidatePath(`/artworks/${artworkId}`);
return { ok: true };
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { getCategoriesWithTags } from "@/actions/categories/getCategories";
import { getTags } from "@/actions/tags/getTags";
import ArtworkColors from "@/components/artworks/single/ArtworkColors";
import ArtworkDetails from "@/components/artworks/single/ArtworkDetails";
import ArtworkTimelapse from "@/components/artworks/single/ArtworkTimelapse";
import ArtworkVariants from "@/components/artworks/single/ArtworkVariants";
import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton";
import EditArtworkForm from "@/components/artworks/single/EditArtworkForm";
@ -27,10 +28,13 @@ 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">
<div>
{item && <ArtworkTimelapse artworkId={item.id} timelapse={item.timelapse} />}
</div>
<div>
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
</div>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import {
confirmArtworkTimelapseUpload,
createArtworkTimelapseUpload,
deleteArtworkTimelapse,
setArtworkTimelapseEnabled,
} from "@/actions/artworks/timelapse";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
type Timelapse = {
enabled: boolean;
s3Key: string;
fileName: string | null;
mimeType: string | null;
sizeBytes: number | null;
};
function fmtBytes(bytes: number) {
const mb = bytes / (1024 * 1024);
if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`;
return `${mb.toFixed(2)} MB`;
}
export default function ArtworkTimelapse({
artworkId,
timelapse,
}: {
artworkId: string;
timelapse: Timelapse | null;
}) {
const [isBusy, startTransition] = React.useTransition();
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
startTransition(async () => {
try {
// 1) create presigned url
const presign = await createArtworkTimelapseUpload({
artworkId,
fileName: file.name,
mimeType: file.type || "application/octet-stream",
sizeBytes: file.size,
});
// 2) upload to S3 directly
const putRes = await fetch(presign.uploadUrl, {
method: "PUT",
headers: {
"Content-Type": presign.mimeType,
},
body: file,
});
if (!putRes.ok) {
throw new Error(`Upload failed (${putRes.status})`);
}
// 3) persist in DB
await confirmArtworkTimelapseUpload({
artworkId,
s3Key: presign.s3Key,
fileName: presign.fileName,
mimeType: presign.mimeType,
sizeBytes: presign.sizeBytes,
});
toast.success("Timelapse uploaded");
e.target.value = "";
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed");
}
});
}
function onToggleEnabled(next: boolean) {
startTransition(async () => {
try {
await setArtworkTimelapseEnabled({ artworkId, enabled: next });
toast.success("Timelapse updated");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Update failed");
}
});
}
function onDelete() {
startTransition(async () => {
try {
await deleteArtworkTimelapse({ artworkId });
toast.success("Timelapse deleted");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
});
}
const src = timelapse?.s3Key ? `/api/image/${timelapse.s3Key}` : null;
return (
<div className="rounded-md border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-medium">Timelapse</div>
<div className="text-sm text-muted-foreground">
Upload a timelapse video for this artwork (stored in S3).
</div>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="timelapse-enabled" className="text-sm">
Display
</Label>
<Switch
id="timelapse-enabled"
disabled={!timelapse || isBusy}
checked={timelapse?.enabled ?? false}
onCheckedChange={onToggleEnabled}
/>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-sm">
{timelapse ? (
<div className="space-y-1">
<div>
<span className="text-muted-foreground">File:</span>{" "}
{timelapse.fileName ?? timelapse.s3Key}
</div>
{typeof timelapse.sizeBytes === "number" ? (
<div className="text-muted-foreground">Size: {fmtBytes(timelapse.sizeBytes)}</div>
) : null}
</div>
) : (
<div className="text-muted-foreground">No timelapse uploaded yet.</div>
)}
</div>
<div className="flex items-center gap-2">
<input
type="file"
accept="video/*"
onChange={onPickFile}
disabled={isBusy}
className="block text-sm"
/>
{timelapse ? (
<Button variant="destructive" onClick={onDelete} disabled={isBusy}>
Delete
</Button>
) : null}
</div>
</div>
{src ? (
<video className="w-full rounded-md border" controls preload="metadata" src={src} />
) : null}
</div>
);
}

View File

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

View File

@ -1,32 +1,50 @@
"use client"
"use client";
import { generateAltTextForArtwork } from "@/actions/artworks/generateAltText";
import { updateArtwork } from "@/actions/artworks/updateArtwork";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
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 { 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 { format } from "date-fns";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod/v4";
import type { z } from "zod/v4";
export default function EditArtworkForm({ artwork, categories, tags }:
{
artwork: ArtworkWithRelations,
categories: CategoryWithTags[]
tags: ArtTag[]
}) {
export default function EditArtworkForm({
artwork,
categories,
tags,
}: {
artwork: ArtworkWithRelations;
categories: CategoryWithTags[];
tags: ArtTag[];
}) {
const router = useRouter();
const [isGeneratingAlt, startAltTransition] = useTransition();
const form = useForm<z.infer<typeof artworkSchema>>({
resolver: zodResolver(artworkSchema),
defaultValues: {
@ -41,19 +59,21 @@ export default function EditArtworkForm({ artwork, categories, tags }:
notes: artwork.notes || "",
month: artwork.month || undefined,
year: artwork.year || undefined,
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
tagIds: artwork.tags?.map(tag => tag.id) ?? [],
creationDate: artwork.creationDate
? new Date(artwork.creationDate)
: undefined,
categoryIds: artwork.categories?.map((cat) => cat.id) ?? [],
tagIds: artwork.tags?.map((tag) => tag.id) ?? [],
newCategoryNames: [],
newTagNames: []
}
})
newTagNames: [],
},
});
async function onSubmit(values: z.infer<typeof artworkSchema>) {
const updatedArtwork = await updateArtwork(values, artwork.id)
const updatedArtwork = await updateArtwork(values, artwork.id);
if (updatedArtwork) {
toast.success("Artwork updated")
router.push(`/artworks`)
toast.success("Artwork updated");
router.push(`/artworks`);
}
}
@ -80,10 +100,42 @@ export default function EditArtworkForm({ artwork, categories, tags }:
name="altText"
render={({ field }) => (
<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>
<Input {...field} placeholder="Alt for this image" />
</FormControl>
<FormDescription>
Generates a caption from the original image. CPU-only can take
1030s.
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" />
<Textarea
{...field}
placeholder="A descriptive text to the image"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -125,9 +180,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input
{...field}
type="number"
value={field.value ?? ''}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
}
/>
</FormControl>
@ -145,9 +202,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input
{...field}
type="number"
value={field.value ?? ''}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
}
/>
</FormControl>
@ -170,10 +229,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
variant="outline"
className={cn(
"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>
</FormControl>
</PopoverTrigger>
@ -182,7 +243,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date)
field.onChange(date);
}}
initialFocus
fromYear={1990}
@ -236,17 +297,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => {
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
.filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim())
.filter(Boolean);
field.onChange(existingIds);
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"newCategoryNames",
Array.from(new Set(newNames)),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
/>
</FormControl>
@ -276,7 +343,8 @@ export default function EditArtworkForm({ artwork, categories, tags }:
.map((t) => {
let group = "Other tags";
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 };
})
@ -301,12 +369,19 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<MultipleSelector
options={tagOptions}
groupBy="group"
groupOrder={["Selected", "From selected categories", "Other tags"]}
groupOrder={[
"Selected",
"From selected categories",
"Other tags",
]}
showSelectedInDropdown
placeholder="Select or type to create tags"
hidePlaceholderWhenSelected
selectFirstItem
value={[...selectedExistingOptions, ...selectedNewOptions]}
value={[
...selectedExistingOptions,
...selectedNewOptions,
]}
creatable
createOption={(raw) => ({
value: `__new__:${raw}`,
@ -316,17 +391,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => {
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
.filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim())
.filter(Boolean);
field.onChange(existingIds);
form.setValue("newTagNames", Array.from(new Set(newNames)), {
shouldDirty: true,
shouldValidate: true,
});
form.setValue(
"newTagNames",
Array.from(new Set(newNames)),
{
shouldDirty: true,
shouldValidate: true,
},
);
}}
/>
</FormControl>
@ -347,7 +428,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -359,10 +443,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
<FormDescription>
This image contains sensitive or adult content.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -374,10 +463,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
<FormDescription>
Will this image be published.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -389,10 +483,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<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>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -444,10 +543,16 @@ export default function EditArtworkForm({ artwork, categories, tags }:
/> */}
<div className="flex flex-col gap-4">
<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>
</form>
</Form>
</div >
</div>
);
}

View File

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

View File

@ -1,4 +1,4 @@
import { Prisma } from "@/generated/prisma/client";
import type { Prisma } from "@/generated/prisma/client";
export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
include: {
@ -10,6 +10,7 @@ export type ArtworkWithRelations = Prisma.ArtworkGetPayload<{
colors: true;
tags: true;
variants: true;
timelapse: true;
};
}>;