From 1ae0cf29924227e8de6fccff8941966d686ab133 Mon Sep 17 00:00:00 2001 From: Gunshiz Date: Thu, 28 May 2026 20:48:38 +0700 Subject: [PATCH] feat: add submission deletion support, transition webhook templates to index-based placeholders, enforce image-only uploads, and localize UI to Thai. --- app/actions/submissions.ts | 35 ++++++++++--- app/actions/upload.ts | 14 ++++- app/form/admin/extra/client.tsx | 4 +- app/form/admin/result/[id]/client.tsx | 1 - app/form/client.tsx | 75 ++++++++++++++++++++++----- app/form/layout.tsx | 4 +- app/layout.tsx | 2 +- 7 files changed, 108 insertions(+), 27 deletions(-) diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 05a1f00..aea7c2a 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -96,23 +96,24 @@ export async function submitForm( if (form.discordWebhookUrl && templateToUse) { try { const questionsList = await db.query.questions.findMany({ - where: (q, { eq }) => eq(q.formId, formId) + where: (q, { eq }) => eq(q.formId, formId), + orderBy: (q, { asc }) => asc(q.displayOrder) }); - const questionMap = questionsList.reduce((acc, q) => { - acc[q.id] = q.label; + const questionMap = questionsList.reduce((acc, q, index) => { + acc[q.id] = String(index + 1); return acc; }, {} as Record); const answersDict = answersList.reduce((acc, a) => { - const label = questionMap[a.questionId]; - if (label) { - acc[label] = a.value; + const idx = questionMap[a.questionId]; + if (idx) { + acc[idx] = a.value; } return acc; }, {} as Record); let message = templateToUse; - // Replace {Label} with value + // Replace {1}, {2}, etc. with value message = message.replace(/\{([^}]+)\}/g, (match: string, p1: string) => { return answersDict[p1] || "Not Provided"; }); @@ -152,3 +153,23 @@ export async function deleteAllSubmissions() { revalidatePath("/form/admin/result"); return { ok: true }; } + +export async function deleteOwnSubmission(formId: string) { + const session = await getServerSession(authOptions); + if (!session?.user) throw new Error("Not authenticated"); + + const discordId = (session.user as { discordId?: string }).discordId; + if (!discordId) throw new Error("No Discord ID found"); + + const { and } = await import("drizzle-orm"); + + await db.delete(submissions).where( + and( + eq(submissions.formId, formId), + eq(submissions.userDiscordId, discordId) + ) + ); + revalidatePath(`/form`); + revalidatePath("/form/admin/result"); + return { ok: true }; +} diff --git a/app/actions/upload.ts b/app/actions/upload.ts index 1e10b6e..1eb95b1 100644 --- a/app/actions/upload.ts +++ b/app/actions/upload.ts @@ -20,8 +20,18 @@ export async function uploadImage(formData: FormData): Promise { const file = formData.get("file") as File; if (!file) throw new Error("No file provided"); - const ext = file.name.split(".").pop() ?? "png"; - const filename = `${crypto.randomUUID()}.${ext}`; + if (!file.type.startsWith("image/")) { + throw new Error("Only image files are allowed"); + } + + const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"]; + const originalExt = (file.name.split(".").pop() || "").toLowerCase(); + + if (!allowedExtensions.includes(originalExt)) { + throw new Error("Invalid image extension"); + } + + const filename = `${crypto.randomUUID()}.${originalExt}`; const uploadDir = path.join(process.cwd(), "public", "form"); await mkdir(uploadDir, { recursive: true }); diff --git a/app/form/admin/extra/client.tsx b/app/form/admin/extra/client.tsx index 7195bd5..196a3eb 100644 --- a/app/form/admin/extra/client.tsx +++ b/app/form/admin/extra/client.tsx @@ -73,7 +73,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { className="min-h-24" />

- Use {`{QuestionLabel}`} to insert answers. Example: {`{Name}`} or {`{skill}`}. + Use {`{1}`}, {`{2}`} to insert answers according to question order. Example: {`{1}`} for the first question.

@@ -86,7 +86,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { className="min-h-24" />

- Used when a user updates their existing submission. Use {`{QuestionLabel}`} to insert answers. + Used when a user updates their existing submission. Use {`{1}`}, {`{2}`} to insert answers.

diff --git a/app/form/admin/result/[id]/client.tsx b/app/form/admin/result/[id]/client.tsx index b02ddb5..c0039f6 100644 --- a/app/form/admin/result/[id]/client.tsx +++ b/app/form/admin/result/[id]/client.tsx @@ -52,7 +52,6 @@ export default function SubmissionDetailClient({ if (!text) return; navigator.clipboard.writeText(text); setCopiedId(id); - toast.success("Answer copied to clipboard!"); setTimeout(() => setCopiedId(null), 2000); }; diff --git a/app/form/client.tsx b/app/form/client.tsx index eae2446..05d4cf6 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -10,8 +10,9 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; -import { submitForm } from "@/app/actions/submissions"; +import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions"; import { Loader2, LogOut } from "lucide-react"; import { signOut } from "next-auth/react"; import Image from "next/image"; @@ -141,13 +142,13 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF if (q.type === "checkbox") { const selected = checkboxAnswers[q.id] || []; if (selected.length === 0) { - toast.error(`"${q.label}" is required.`); + toast.error(`จำเป็นต้องตอบ "${q.label}"`); return; } } else { const ans = answers[q.id] || ""; if (!ans.trim()) { - toast.error(`"${q.label}" is required.`); + toast.error(`จำเป็นต้องตอบ "${q.label}"`); return; } } @@ -171,12 +172,31 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF const res = await submitForm(form.id, formattedAnswers); if (res.ok) { - toast.success(isUpdating ? "Form updated successfully!" : "Form submitted successfully!"); + toast.success(isUpdating ? "อัปเดตฟอร์มสำเร็จ!" : "ส่งฟอร์มสำเร็จ!"); // Fields are kept as-is; localStorage already reflects current state } } catch (err: any) { console.error(err); - toast.error(err.message || "Failed to submit form."); + toast.error(err.message || "ไม่สามารถส่งฟอร์มได้"); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async () => { + setSubmitting(true); + try { + const res = await deleteOwnSubmission(form.id); + if (res.ok) { + toast.success("ลบข้อมูลสำเร็จ!"); + localStorage.removeItem(LS_KEY(form.id)); + setAnswers({}); + setCheckboxAnswers({}); + router.refresh(); + } + } catch (err: any) { + console.error(err); + toast.error(err.message || "ไม่สามารถลบข้อมูลได้"); } finally { setSubmitting(false); } @@ -189,7 +209,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF - This form is disabled for now + ฟอร์มนี้ถูกปิดใช้งานชั่วคราว @@ -245,7 +265,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF {q.type === "text" && ( handleTextChange(q.id, e.target.value)} @@ -256,7 +276,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF {q.type === "textarea" && (