Add commission type example image
This commit is contained in:
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])
|
||||||
|
|||||||
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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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,7 +14,11 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user