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:
@@ -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<string, string>);
|
||||
|
||||
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<string, string>);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
+12
-2
@@ -20,8 +20,18 @@ export async function uploadImage(formData: FormData): Promise<string> {
|
||||
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 });
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
|
||||
className="min-h-24"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -86,7 +86,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
|
||||
className="min-h-24"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+63
-12
@@ -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
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-xl">
|
||||
This form is disabled for now
|
||||
ฟอร์มนี้ถูกปิดใช้งานชั่วคราว
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -245,7 +265,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
|
||||
{q.type === "text" && (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Your answer"
|
||||
placeholder="คำตอบของคุณ"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
@@ -256,7 +276,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
|
||||
|
||||
{q.type === "textarea" && (
|
||||
<Textarea
|
||||
placeholder="Your answer"
|
||||
placeholder="คำตอบของคุณ"
|
||||
required={q.required}
|
||||
value={answers[q.id] || ""}
|
||||
onChange={(e) => handleTextChange(q.id, e.target.value)}
|
||||
@@ -311,17 +331,48 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
|
||||
))}
|
||||
</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">
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting
|
||||
กำลังส่ง
|
||||
</>
|
||||
) : isUpdating ? (
|
||||
"Update Submission"
|
||||
"อัปเดตข้อมูล"
|
||||
) : (
|
||||
"Submit"
|
||||
"ส่ง"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ export default async function FormLayout({
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<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">
|
||||
Forms
|
||||
<span className="font-semibold text-sm">
|
||||
ฟอร์ม
|
||||
</span>
|
||||
{session?.user && (
|
||||
<FormLayoutClient
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export default function RootLayout({
|
||||
>
|
||||
<body className={`${anuphan.className} ${geistSans.className}`}>
|
||||
<TooltipProvider>
|
||||
<Toaster richColors closeButton position="bottom-right" theme="dark" />
|
||||
<Toaster richColors closeButton position="bottom-right" theme="dark" toastOptions={{ className: anuphan.className }} />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user