diff --git a/prisma/migrations/20260131151654_com_6/migration.sql b/prisma/migrations/20260131151654_com_6/migration.sql new file mode 100644 index 0000000..a16863b --- /dev/null +++ b/prisma/migrations/20260131151654_com_6/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CommissionGuidelines" ADD COLUMN "exampleImageUrl" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 57a7609..2c79f4d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -399,6 +399,7 @@ model CommissionGuidelines { updatedAt DateTime @updatedAt markdown String + exampleImageUrl String? isActive Boolean @default(true) @@index([isActive]) diff --git a/src/actions/commissions/examples.ts b/src/actions/commissions/examples.ts new file mode 100644 index 0000000..4089633 --- /dev/null +++ b/src/actions/commissions/examples.ts @@ -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 { + 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 { + 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, + }) + ); +} diff --git a/src/actions/commissions/guidelines/getGuidelines.ts b/src/actions/commissions/guidelines/getGuidelines.ts index fef8306..ad39822 100644 --- a/src/actions/commissions/guidelines/getGuidelines.ts +++ b/src/actions/commissions/guidelines/getGuidelines.ts @@ -2,10 +2,21 @@ import { prisma } from "@/lib/prisma"; -export async function getActiveGuidelines(): Promise { +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, + }; } diff --git a/src/actions/commissions/guidelines/saveGuidelines.ts b/src/actions/commissions/guidelines/saveGuidelines.ts index b4f42ae..d2f63eb 100644 --- a/src/actions/commissions/guidelines/saveGuidelines.ts +++ b/src/actions/commissions/guidelines/saveGuidelines.ts @@ -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, }, }); -} \ No newline at end of file +} diff --git a/src/app/(admin)/commissions/guidelines/page.tsx b/src/app/(admin)/commissions/guidelines/page.tsx index f762cfe..61d7de4 100644 --- a/src/app/(admin)/commissions/guidelines/page.tsx +++ b/src/app/(admin)/commissions/guidelines/page.tsx @@ -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 (
@@ -10,8 +14,12 @@ export default async function CommissionGuidelinesPage() {

Commission Guidelines

- +
); -} \ No newline at end of file +} diff --git a/src/components/commissions/guidelines/Editor.tsx b/src/components/commissions/guidelines/Editor.tsx index 96a1447..000329f 100644 --- a/src/components/commissions/guidelines/Editor.tsx +++ b/src/components/commissions/guidelines/Editor.tsx @@ -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(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 ( {/* Provides editor context */} +
+
+ +
+ + {selectedUrl ? ( + + Open + + ) : null} +
+
+ +
+ { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.currentTarget.value = ""; + }} + /> + +
+
{/* Blocks */} editor.tf.h1.toggle()} tooltip="Heading 1"> @@ -110,4 +212,4 @@ export default function GuidelinesEditor({ markdown }: { markdown: string | null
); -} \ No newline at end of file +}