feat: add submission deletion support, transition webhook templates to index-based placeholders, enforce image-only uploads, and localize UI to Thai.

This commit is contained in:
2026-05-28 20:48:38 +07:00 Unverified
parent dedf92965b
commit 1ae0cf2992
7 changed files with 108 additions and 27 deletions
+28 -7
View File
@@ -96,23 +96,24 @@ export async function submitForm(
if (form.discordWebhookUrl && templateToUse) { if (form.discordWebhookUrl && templateToUse) {
try { try {
const questionsList = await db.query.questions.findMany({ 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) => { const questionMap = questionsList.reduce((acc, q, index) => {
acc[q.id] = q.label; acc[q.id] = String(index + 1);
return acc; return acc;
}, {} as Record<string, string>); }, {} as Record<string, string>);
const answersDict = answersList.reduce((acc, a) => { const answersDict = answersList.reduce((acc, a) => {
const label = questionMap[a.questionId]; const idx = questionMap[a.questionId];
if (label) { if (idx) {
acc[label] = a.value; acc[idx] = a.value;
} }
return acc; return acc;
}, {} as Record<string, string>); }, {} as Record<string, string>);
let message = templateToUse; let message = templateToUse;
// Replace {Label} with value // Replace {1}, {2}, etc. with value
message = message.replace(/\{([^}]+)\}/g, (match: string, p1: string) => { message = message.replace(/\{([^}]+)\}/g, (match: string, p1: string) => {
return answersDict[p1] || "Not Provided"; return answersDict[p1] || "Not Provided";
}); });
@@ -152,3 +153,23 @@ export async function deleteAllSubmissions() {
revalidatePath("/form/admin/result"); revalidatePath("/form/admin/result");
return { ok: true }; 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 };
}
+12 -2
View File
@@ -20,8 +20,18 @@ export async function uploadImage(formData: FormData): Promise<string> {
const file = formData.get("file") as File; const file = formData.get("file") as File;
if (!file) throw new Error("No file provided"); if (!file) throw new Error("No file provided");
const ext = file.name.split(".").pop() ?? "png"; if (!file.type.startsWith("image/")) {
const filename = `${crypto.randomUUID()}.${ext}`; 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"); const uploadDir = path.join(process.cwd(), "public", "form");
await mkdir(uploadDir, { recursive: true }); await mkdir(uploadDir, { recursive: true });
+2 -2
View File
@@ -73,7 +73,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
className="min-h-24" className="min-h-24"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Use <code>{`{QuestionLabel}`}</code> to insert answers. Example: <code>{`{Name}`}</code> or <code>{`{skill}`}</code>. Use <code>{`{1}`}</code>, <code>{`{2}`}</code> to insert answers according to question order. Example: <code>{`{1}`}</code> for the first question.
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -86,7 +86,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
className="min-h-24" className="min-h-24"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Used when a user updates their existing submission. Use <code>{`{QuestionLabel}`}</code> to insert answers. Used when a user updates their existing submission. Use <code>{`{1}`}</code>, <code>{`{2}`}</code> to insert answers.
</p> </p>
</div> </div>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
-1
View File
@@ -52,7 +52,6 @@ export default function SubmissionDetailClient({
if (!text) return; if (!text) return;
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopiedId(id); setCopiedId(id);
toast.success("Answer copied to clipboard!");
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
}; };
+63 -12
View File
@@ -10,8 +10,9 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogTrigger, DialogTitle } from "@/components/ui/dialog"; 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 { toast } from "sonner";
import { submitForm } from "@/app/actions/submissions"; import { submitForm, deleteOwnSubmission } from "@/app/actions/submissions";
import { Loader2, LogOut } from "lucide-react"; import { Loader2, LogOut } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
@@ -141,13 +142,13 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
if (q.type === "checkbox") { if (q.type === "checkbox") {
const selected = checkboxAnswers[q.id] || []; const selected = checkboxAnswers[q.id] || [];
if (selected.length === 0) { if (selected.length === 0) {
toast.error(`"${q.label}" is required.`); toast.error(`จำเป็นต้องตอบ "${q.label}"`);
return; return;
} }
} else { } else {
const ans = answers[q.id] || ""; const ans = answers[q.id] || "";
if (!ans.trim()) { if (!ans.trim()) {
toast.error(`"${q.label}" is required.`); toast.error(`จำเป็นต้องตอบ "${q.label}"`);
return; return;
} }
} }
@@ -171,12 +172,31 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const res = await submitForm(form.id, formattedAnswers); const res = await submitForm(form.id, formattedAnswers);
if (res.ok) { 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 // Fields are kept as-is; localStorage already reflects current state
} }
} catch (err: any) { } catch (err: any) {
console.error(err); 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 { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -189,7 +209,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-center text-xl"> <CardTitle className="text-center text-xl">
This form is disabled for now ฟอร์มนี้ถูกปิดใช้งานชั่วคราว
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -245,7 +265,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
{q.type === "text" && ( {q.type === "text" && (
<Input <Input
type="text" type="text"
placeholder="Your answer" placeholder="คำตอบของคุณ"
required={q.required} required={q.required}
value={answers[q.id] || ""} value={answers[q.id] || ""}
onChange={(e) => handleTextChange(q.id, e.target.value)} onChange={(e) => handleTextChange(q.id, e.target.value)}
@@ -256,7 +276,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
{q.type === "textarea" && ( {q.type === "textarea" && (
<Textarea <Textarea
placeholder="Your answer" placeholder="คำตอบของคุณ"
required={q.required} required={q.required}
value={answers[q.id] || ""} value={answers[q.id] || ""}
onChange={(e) => handleTextChange(q.id, e.target.value)} onChange={(e) => handleTextChange(q.id, e.target.value)}
@@ -311,17 +331,48 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
))} ))}
</div> </div>
<div className="flex justify-end items-center pt-4"> <div className="flex justify-end items-center gap-3 pt-4">
{isUpdating && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
disabled={submitting || !form.isOpen}
>
ลบ
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>ยืนยันการลบข้อมูล?</AlertDialogTitle>
<AlertDialogDescription>
คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลที่ส่งไปแล้ว? การกระทำนี้ไม่สามารถย้อนกลับได้
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>ยกเลิก</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={submitting}
>
ยืนยันการลบ
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<Button type="submit" disabled={submitting || !form.isOpen} className="min-w-28"> <Button type="submit" disabled={submitting || !form.isOpen} className="min-w-28">
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting กำลังส่ง
</> </>
) : isUpdating ? ( ) : isUpdating ? (
"Update Submission" "อัปเดตข้อมูล"
) : ( ) : (
"Submit" "ส่ง"
)} )}
</Button> </Button>
</div> </div>
+2 -2
View File
@@ -13,8 +13,8 @@ export default async function FormLayout({
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-3 flex items-center justify-between"> <header className="border-b border-border px-6 py-3 flex items-center justify-between">
<span className="font-semibold text-sm text-muted-foreground tracking-wide uppercase"> <span className="font-semibold text-sm">
Forms ฟอร์ม
</span> </span>
{session?.user && ( {session?.user && (
<FormLayoutClient <FormLayoutClient
+1 -1
View File
@@ -33,7 +33,7 @@ export default function RootLayout({
> >
<body className={`${anuphan.className} ${geistSans.className}`}> <body className={`${anuphan.className} ${geistSans.className}`}>
<TooltipProvider> <TooltipProvider>
<Toaster richColors closeButton position="bottom-right" theme="dark" /> <Toaster richColors closeButton position="bottom-right" theme="dark" toastOptions={{ className: anuphan.className }} />
{children} {children}
</TooltipProvider> </TooltipProvider>
</body> </body>