Add commission type example image

This commit is contained in:
2026-01-31 16:37:24 +01:00
parent 51cfde4d78
commit e869f19142
7 changed files with 229 additions and 12 deletions

View File

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

View File

@ -399,6 +399,7 @@ model CommissionGuidelines {
updatedAt DateTime @updatedAt
markdown String
exampleImageUrl String?
isActive Boolean @default(true)
@@index([isActive])

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

@ -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

@ -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">