From fa35f00f3845f67604a2a58df653ef49cf8c7ad3 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 28 Jul 2026 00:02:34 +0700 Subject: [PATCH] feat:update form --- app/actions/questions.ts | 15 +- app/actions/submissions.ts | 89 ++++-- app/admin/form/[id]/client.tsx | 84 +++++- .../[id]/result/[submissionId]/client.tsx | 80 ++++- .../form/[id]/result/[submissionId]/page.tsx | 7 +- app/admin/form/[id]/result/export/route.ts | 31 +- app/admin/form/[id]/result/page.tsx | 2 +- app/admin/form/actions.ts | 2 +- app/api/upload/route.ts | 58 ++-- app/cdn/[id]/route.ts | 18 ++ app/form/[id]/page.tsx | 41 ++- app/form/client.tsx | 274 +++++++++++++++++- app/form/page.tsx | 2 +- components/results-filters.tsx | 49 ++-- db/schema/form.ts | 41 ++- drizzle/meta/_journal.json | 9 +- lib/cdn-images.test.ts | 53 ++++ lib/cdn-images.ts | 29 ++ lib/cdn-response.ts | 16 + lib/form-answer-values.test.ts | 6 + lib/form-answer-values.ts | 16 + lib/submission-intake.test.ts | 42 ++- lib/submission-intake.ts | 83 +++++- lib/submission-webhook.test.ts | 18 ++ lib/submission-webhook.ts | 3 +- 25 files changed, 919 insertions(+), 149 deletions(-) create mode 100644 app/cdn/[id]/route.ts create mode 100644 lib/cdn-images.test.ts create mode 100644 lib/cdn-images.ts create mode 100644 lib/cdn-response.ts diff --git a/app/actions/questions.ts b/app/actions/questions.ts index 094eed5..504c218 100644 --- a/app/actions/questions.ts +++ b/app/actions/questions.ts @@ -3,8 +3,8 @@ import { db } from "@/db"; import { unlink } from "fs/promises"; import path from "path"; -import { questions } from "@/db/schema"; -import type { QuestionType } from "@/db/schema"; +import { cdn, questions } from "@/db/schema"; +import type { ImageAnswerMode, QuestionType } from "@/db/schema"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/lib/auth"; @@ -55,6 +55,7 @@ export async function updateQuestion( options: string[]; imageUrl: string | null; allowOther: boolean; + imageAnswerMode: ImageAnswerMode; }> ) { await requireAdmin(); @@ -91,6 +92,7 @@ export async function bulkUpdateQuestions( displayOrder: number; options: string[]; allowOther: boolean; + imageAnswerMode: ImageAnswerMode; }[] ) { await requireAdmin(); @@ -107,6 +109,7 @@ export async function bulkUpdateQuestions( displayOrder: u.displayOrder, options: u.options ?? [], allowOther: u.allowOther, + imageAnswerMode: u.imageAnswerMode, }) .where(eq(questions.id, u.id)) ) @@ -123,6 +126,14 @@ export async function bulkUpdateQuestions( async function deleteImageFile(imageUrl: string | null) { if (!imageUrl) return; + if (imageUrl.startsWith("/cdn/")) { + const id = imageUrl.split("/").pop(); + if (id) { + await db.delete(cdn).where(eq(cdn.id, id)); + } + return; + } + try { const filename = imageUrl.split("/").pop(); if (filename) { diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 9f52889..5221720 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -8,13 +8,17 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { sse } from "@/lib/sse"; -import type { FormAnswerValue } from "@/lib/form-answer-values"; -import { normalizeSubmissionAnswers } from "@/lib/submission-intake"; +import type { FormAnswerPayload } from "@/lib/form-answer-values"; +import { + normalizeSubmissionAnswers, + type StoredAnswerSnapshot, +} from "@/lib/submission-intake"; import { submissionWebhookMessage } from "@/lib/submission-webhook"; export async function submitForm( formId: string, - answersList: { questionId: string; value: FormAnswerValue }[] + answersList: FormAnswerPayload[], + targetSubmissionId?: string | null ) { const session = await getServerSession(authOptions); if (!session?.user) throw new Error("Not authenticated"); @@ -33,17 +37,49 @@ export async function submitForm( } const normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList); + const imageCdnIds = [ + ...new Set(normalizedAnswers.map((answer) => answer.imageCdnId).filter(Boolean)), + ] as string[]; + + if (imageCdnIds.length > 0) { + const ownedImages = await db.query.cdn.findMany({ + where: (record, { and, eq, inArray }) => + and( + inArray(record.id, imageCdnIds), + eq(record.ownerDiscordId, discordId) + ), + columns: { id: true }, + }); + if (ownedImages.length !== imageCdnIds.length) { + throw new Error("ข้อมูลรูปภาพไม่ถูกต้อง"); + } + } const result = await db.transaction(async (tx) => { - const existingSubmission = await tx.query.submissions.findFirst({ - where: (submission, { eq, and }) => - and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)), - }); + const existingSubmission = form.allowMultipleSubmissions + ? targetSubmissionId + ? await tx.query.submissions.findFirst({ + where: (submission, { eq, and }) => + and( + eq(submission.id, targetSubmissionId), + eq(submission.formId, formId), + eq(submission.userDiscordId, discordId) + ), + }) + : null + : await tx.query.submissions.findFirst({ + where: (submission, { eq, and }) => + and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)), + }); + + if (form.allowMultipleSubmissions && targetSubmissionId && !existingSubmission) { + throw new Error("ไม่พบคำตอบที่ต้องการแก้ไข"); + } let submissionId = ""; let submittedAt = new Date(); const isUpdate = Boolean(existingSubmission); - let oldAnswersDict: Record = {}; + let oldAnswersDict: Record = {}; if (existingSubmission) { submissionId = existingSubmission.id; @@ -53,8 +89,11 @@ export async function submitForm( where: (answer, { eq }) => eq(answer.submissionId, submissionId), }); - oldAnswersDict = oldAnswersList.reduce>((acc, curr) => { - acc[curr.questionId] = curr.value; + oldAnswersDict = oldAnswersList.reduce>((acc, curr) => { + acc[curr.questionId] = { + value: curr.value, + imageCdnId: curr.imageCdnId ?? null, + }; return acc; }, {}); @@ -91,6 +130,7 @@ export async function submitForm( submissionId, questionId: answer.questionId, value: answer.value, + imageCdnId: answer.imageCdnId, })) ); } @@ -207,19 +247,32 @@ export async function deleteAllSubmissions(formId: string) { return { ok: true }; } -export async function deleteOwnSubmission(formId: string) { +export async function deleteOwnSubmission(formId: string, submissionId?: string | null) { const discordId = await requireDiscordId(); - const { and } = await import("drizzle-orm"); + const form = await requireFormAccess(formId, discordId); + if (form.allowMultipleSubmissions && !submissionId) { + throw new Error("ไม่พบคำตอบที่ต้องการลบ"); + } - await db.delete(submissions).where( - and( - eq(submissions.formId, formId), - eq(submissions.userDiscordId, discordId) - ) - ); + const where = form.allowMultipleSubmissions + ? and( + eq(submissions.id, submissionId!), + eq(submissions.formId, formId), + eq(submissions.userDiscordId, discordId) + ) + : and( + eq(submissions.formId, formId), + eq(submissions.userDiscordId, discordId) + ); + + const deleted = await db.delete(submissions).where(where).returning({ id: submissions.id }); + if (form.allowMultipleSubmissions && deleted.length === 0) { + throw new Error("ไม่พบคำตอบที่ต้องการลบ"); + } void sse.submissions.pub("update", { formId, + submissionId: submissionId ?? undefined, action: "deleted", }); revalidatePath(`/form`); diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx index 855c516..441c4ab 100644 --- a/app/admin/form/[id]/client.tsx +++ b/app/admin/form/[id]/client.tsx @@ -61,6 +61,12 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; interface Question { id: string; @@ -72,6 +78,7 @@ interface Question { required: boolean; options: string[] | null; allowOther: boolean; + imageAnswerMode: "none" | "optional" | "required"; } interface FormEditorClientProps { @@ -80,6 +87,7 @@ interface FormEditorClientProps { title: string; description: string | null; isOpen: boolean; + allowMultipleSubmissions: boolean; }; initialQuestions: Question[]; } @@ -89,6 +97,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const [formTitle, setFormTitle] = useState(form.title); const [formDescription, setFormDescription] = useState(form.description || ""); const [formIsOpen, setFormIsOpen] = useState(form.isOpen); + const [allowMultipleSubmissions, setAllowMultipleSubmissions] = useState( + form.allowMultipleSubmissions + ); const [questions, setQuestions] = useState(initialQuestions); const [uploadingId, setUploadingId] = useState(null); const [addingQuestion, setAddingQuestion] = useState(false); @@ -97,14 +108,16 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC const formTitleRef = useRef(formTitle); const formDescriptionRef = useRef(formDescription); const formIsOpenRef = useRef(formIsOpen); + const allowMultipleSubmissionsRef = useRef(allowMultipleSubmissions); const questionsRef = useRef(questions); useEffect(() => { formTitleRef.current = formTitle; formDescriptionRef.current = formDescription; formIsOpenRef.current = formIsOpen; + allowMultipleSubmissionsRef.current = allowMultipleSubmissions; questionsRef.current = questions; - }, [formDescription, formIsOpen, formTitle, questions]); + }, [allowMultipleSubmissions, formDescription, formIsOpen, formTitle, questions]); const saveForm = useCallback(async () => { const title = formTitleRef.current; @@ -115,6 +128,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC title: title.trim(), description: formDescriptionRef.current, isOpen: formIsOpenRef.current, + allowMultipleSubmissions: allowMultipleSubmissionsRef.current, }); router.refresh(); }, [form.id, router]); @@ -125,6 +139,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC ...q, options: q.options === null ? [] : q.options, allowOther: q.allowOther, + imageAnswerMode: q.imageAnswerMode, })); await bulkUpdateQuestions(form.id, formattedUpdates); router.refresh(); @@ -154,6 +169,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC formAutoSave.schedule(); }; + const handleAllowMultipleSubmissionsChange = (val: boolean) => { + allowMultipleSubmissionsRef.current = val; + setAllowMultipleSubmissions(val); + formAutoSave.schedule(); + }; + const handleAddQuestion = async () => { setAddingQuestion(true); try { @@ -334,9 +355,10 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC /> - -
- + + + + Form Title Form title is required.

)} -
-
- + + + Description handleFormDescriptionChange(html)} placeholder="Provide a description..." className="min-h-20 w-full" /> -
-
+ + - -
+ + + + +
+ + Allow multiple submissions + + + Users can create separate responses instead of only editing their current response. + +
+
+
@@ -566,6 +604,28 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC Required + + Answer image + + {(q.type === "radio" || q.type === "checkbox") && ( diff --git a/app/admin/form/[id]/result/[submissionId]/client.tsx b/app/admin/form/[id]/result/[submissionId]/client.tsx index 3f1e88e..5ba999a 100644 --- a/app/admin/form/[id]/result/[submissionId]/client.tsx +++ b/app/admin/form/[id]/result/[submissionId]/client.tsx @@ -26,6 +26,7 @@ import Image from 'next/image'; import { stripHtml } from "@/lib/utils"; import { HtmlDisplay } from "@/components/html-display"; import { formatStoredAnswer } from "@/lib/form-answer-values"; +import { normalizeStoredAnswerSnapshot } from "@/lib/submission-intake"; interface Question { id: string; @@ -40,10 +41,10 @@ interface SubmissionDetailClientProps { userName: string | null; userDiscordId: string | null; submittedAt: Date; - editHistory?: { editedAt: string; oldAnswers: Record }[] | null; + editHistory?: { editedAt: string; oldAnswers: Record }[] | null; }; questions: Question[]; - answers: Record; + answers: Record; } export default function SubmissionDetailClient({ @@ -103,16 +104,25 @@ export default function SubmissionDetailClient({ {/* Answers List */}
{questions.map((q) => { - const rawAnswerValue = answers[q.id] || ""; + const rawAnswer = answers[q.id] ?? { value: "", imageCdnId: null }; + const rawAnswerValue = rawAnswer.value; const answerValue = formatStoredAnswer(rawAnswerValue, q.type); + const imageUrl = rawAnswer.imageCdnId ? `/cdn/${rawAnswer.imageCdnId}` : ""; const wasEdited = editHistory.some( - (edit) => (edit.oldAnswers[q.id] ?? "") !== rawAnswerValue + (edit) => { + const oldSnapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]); + return oldSnapshot.value !== rawAnswerValue || oldSnapshot.imageCdnId !== rawAnswer.imageCdnId; + } ); const qAnswerHistory = editHistory.map((edit) => ({ editedAt: edit.editedAt, - answer: formatStoredAnswer(edit.oldAnswers[q.id], q.type), + answer: (() => { + const snapshot = normalizeStoredAnswerSnapshot(edit.oldAnswers[q.id]); + const text = formatStoredAnswer(snapshot.value, q.type); + return text || (snapshot.imageCdnId ? `/cdn/${snapshot.imageCdnId}` : ""); + })(), })); return ( @@ -171,8 +181,8 @@ export default function SubmissionDetailClient({
)} - - {answerValue ? ( + + {answerValue && (
{wasEdited && ( @@ -198,7 +208,11 @@ export default function SubmissionDetailClient({ )}
- ) : ( + )} + {imageUrl && ( + + )} + {!answerValue && !imageUrl && ( No response provided @@ -255,3 +269,53 @@ export default function SubmissionDetailClient({
); } + +function AnswerImagePreview({ src, label }: { src: string; label: string }) { + const [unavailable, setUnavailable] = useState(false); + + if (unavailable) { + return ( + + Image unavailable + + ); + } + + return ( + + + + + + + Image answer + {label} + + setUnavailable(true)} + /> + + + ); +} diff --git a/app/admin/form/[id]/result/[submissionId]/page.tsx b/app/admin/form/[id]/result/[submissionId]/page.tsx index b2d98f0..133d03e 100644 --- a/app/admin/form/[id]/result/[submissionId]/page.tsx +++ b/app/admin/form/[id]/result/[submissionId]/page.tsx @@ -47,9 +47,12 @@ export default async function SubmissionDetailPage({ where: (a, { eq }) => eq(a.submissionId, submission.id), }); - const answersMap: Record = {}; + const answersMap: Record = {}; for (const ans of answersList) { - answersMap[ans.questionId] = ans.value; + answersMap[ans.questionId] = { + value: ans.value, + imageCdnId: ans.imageCdnId ?? null, + }; } const submissionsList = await db.query.submissions.findMany({ where: (s, { eq }) => eq(s.formId, formId), diff --git a/app/admin/form/[id]/result/export/route.ts b/app/admin/form/[id]/result/export/route.ts index 3ab84b5..221c213 100644 --- a/app/admin/form/[id]/result/export/route.ts +++ b/app/admin/form/[id]/result/export/route.ts @@ -7,7 +7,7 @@ import { getFilteredSubmissions, parseResultFilters, } from "@/lib/admin-form-results"; -import { formatStoredAnswer } from "@/lib/form-answer-values"; +import { answerCellValue } from "@/lib/form-answer-values"; import { stripHtml } from "@/lib/utils"; function csvCell(value: unknown) { @@ -25,6 +25,24 @@ function safeFilename(value: string) { ); } +function safeAsciiFilename(value: string) { + return ( + value + .normalize("NFKD") + .replace(/[^\x00-\x7F]/g, "") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60) || "form" + ); +} + +function csvContentDisposition(title: string, date: string) { + const unicodeFilename = `${safeFilename(title)}-responses-${date}.csv`; + const asciiFilename = `${safeAsciiFilename(title)}-responses-${date}.csv`; + + return `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(unicodeFilename)}`; +} + export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } @@ -59,7 +77,7 @@ export async function GET( const answerMap = new Map( answerRows.map((answer) => [ `${answer.submissionId}:${answer.questionId}`, - answer.value, + { value: answer.value, imageCdnId: answer.imageCdnId ?? null }, ]) ); const header = [ @@ -83,9 +101,10 @@ export async function GET( ? submission.editHistory.length : 0, ...questions.map((question) => - formatStoredAnswer( - answerMap.get(`${submission.id}:${question.id}`) || "", - question.type + answerCellValue( + answerMap.get(`${submission.id}:${question.id}`)?.value || "", + question.type, + answerMap.get(`${submission.id}:${question.id}`)?.imageCdnId ) ), ] @@ -98,7 +117,7 @@ export async function GET( return new NextResponse(`\uFEFF${lines.join("\r\n")}`, { headers: { "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="${safeFilename(form.title)}-responses-${date}.csv"`, + "Content-Disposition": csvContentDisposition(form.title, date), }, }); } diff --git a/app/admin/form/[id]/result/page.tsx b/app/admin/form/[id]/result/page.tsx index 5983b8c..c5f34c6 100644 --- a/app/admin/form/[id]/result/page.tsx +++ b/app/admin/form/[id]/result/page.tsx @@ -73,7 +73,7 @@ export default async function ResultsListPage({ {total > 0 && } - + {rows.length === 0 ? ( diff --git a/app/admin/form/actions.ts b/app/admin/form/actions.ts index a8a4d7b..04ab89e 100644 --- a/app/admin/form/actions.ts +++ b/app/admin/form/actions.ts @@ -47,7 +47,7 @@ export async function createDefaultForm() { redirect(`/admin/form/${form.id}`); } -export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) { +export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) { await requireAdmin(); if (data.title !== undefined && !data.title.trim()) { throw new Error("Form title is required."); diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 5e30325..f53412d 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,47 +1,41 @@ import { NextResponse } from "next/server"; -import { requireAdmin } from "@/lib/auth"; - -const MAX_UPLOAD_BYTES = 1024 * 1024; +import { db } from "@/db"; +import { cdn } from "@/db/schema"; +import { requireDiscordId } from "@/lib/auth"; +import { validateImageUploadFile, cdnUrl } from "@/lib/cdn-images"; export async function POST(req: Request) { + let discordId: string; try { - try { - await requireAdmin(); - } catch { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + discordId = await requireDiscordId(); + } catch { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { const formData = await req.formData(); - const file = formData.get("file") as File | null; - - if (!file) { + const file = formData.get("file"); + + if (!(file instanceof File)) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - if (!file.type.startsWith("image/")) { - return NextResponse.json({ error: "Only image files are allowed" }, { status: 400 }); - } + validateImageUploadFile(file); - if (file.size > MAX_UPLOAD_BYTES) { - return NextResponse.json({ error: "Image must be 1MB or smaller" }, { status: 400 }); - } + const [record] = await db + .insert(cdn) + .values({ + data: Buffer.from(await file.arrayBuffer()), + name: file.name || null, + type: file.type, + size: file.size, + ownerDiscordId: discordId, + }) + .returning({ id: cdn.id }); - const allowedExtensions = ["png", "jpg", "jpeg", "webp", "gif"]; - const originalExt = (file.name.split(".").pop() || "").toLowerCase(); - - if (!allowedExtensions.includes(originalExt)) { - return NextResponse.json({ error: "Invalid image extension" }, { status: 400 }); - } - - const buffer = Buffer.from(await file.arrayBuffer()); - const base64 = buffer.toString("base64"); - const mimeType = file.type; - const dataUri = `data:${mimeType};base64,${base64}`; - - return NextResponse.json({ url: dataUri }); + return NextResponse.json({ id: record.id, url: cdnUrl(record.id) }); } catch (error) { - console.error("Upload error:", error); const message = error instanceof Error ? error.message : "Internal server error"; - return NextResponse.json({ error: message }, { status: 500 }); + return NextResponse.json({ error: message }, { status: 400 }); } } diff --git a/app/cdn/[id]/route.ts b/app/cdn/[id]/route.ts new file mode 100644 index 0000000..54d62e3 --- /dev/null +++ b/app/cdn/[id]/route.ts @@ -0,0 +1,18 @@ +import { db } from "@/db"; +import { cdnImageResponse } from "@/lib/cdn-response"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const record = await db.query.cdn.findFirst({ + where: (cdn, { eq }) => eq(cdn.id, id), + }); + + if (!record) { + return new Response("Not found", { status: 404 }); + } + + return cdnImageResponse(record); +} diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index 0b71a01..3a87a6c 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -17,9 +17,10 @@ export const dynamic = "force-dynamic"; export default async function FormsSubmitPage( props: { params: Promise<{ id: string }>; + searchParams: Promise<{ submission?: string | string[] }>; } ) { - const params = await props.params; + const [params, query] = await Promise.all([props.params, props.searchParams]); const session = await getServerSession(authOptions); const discordId = getSessionDiscordId(session); @@ -44,10 +45,11 @@ export default async function FormsSubmitPage( orderBy: (q, { asc }) => [asc(q.displayOrder)], }); - const [existingSubmission, submissionStats] = await Promise.all([ - db.query.submissions.findFirst({ + const [ownSubmissions, submissionStats] = await Promise.all([ + db.query.submissions.findMany({ where: (s, { eq, and }) => and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)), + orderBy: (s, { desc }) => [desc(s.submittedAt)], }), getFormSubmissionStats([form.id], discordId), ]); @@ -56,19 +58,36 @@ export default async function FormsSubmitPage( queueNumber: null, }; - const initialAnswers: Record = {}; - if (existingSubmission) { + const requestedSubmissionId = Array.isArray(query.submission) + ? query.submission[0] + : query.submission; + const existingSubmission = form.allowMultipleSubmissions + ? ownSubmissions.find((submission) => submission.id === requestedSubmissionId) ?? null + : ownSubmissions[0] ?? null; + + const answersBySubmissionId: Record< + string, + Record + > = {}; + if (ownSubmissions.length > 0) { const answersList = await db.query.answers.findMany({ - where: (a, { eq }) => eq(a.submissionId, existingSubmission.id), + where: (a, { inArray }) => inArray(a.submissionId, ownSubmissions.map((submission) => submission.id)), }); const questionTypes = new Map(questionsList.map((question) => [question.id, question.type])); for (const answer of answersList) { - initialAnswers[answer.questionId] = + answersBySubmissionId[answer.submissionId] ??= {}; + answersBySubmissionId[answer.submissionId][answer.questionId] = { + value: questionTypes.get(answer.questionId) === "checkbox" ? decodeCheckboxAnswer(answer.value) - : answer.value; + : answer.value, + imageCdnId: answer.imageCdnId ?? null, + }; } } + const initialAnswers = existingSubmission + ? answersBySubmissionId[existingSubmission.id] ?? {} + : {}; return ( <> @@ -79,6 +98,12 @@ export default async function FormsSubmitPage( questions={questionsList} initialAnswers={initialAnswers} hasSubmission={Boolean(existingSubmission)} + selectedSubmissionId={existingSubmission?.id ?? null} + ownSubmissions={ownSubmissions.map((submission) => ({ + id: submission.id, + submittedAt: submission.submittedAt.toISOString(), + answers: answersBySubmissionId[submission.id] ?? {}, + }))} totalAnswers={stats.totalAnswers} queueNumber={stats.queueNumber} /> diff --git a/app/form/client.tsx b/app/form/client.tsx index c4f4693..0055f68 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useRef, useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Card, @@ -92,18 +92,31 @@ interface Question { required: boolean; options: string[] | null; allowOther: boolean; + imageAnswerMode: "none" | "optional" | "required"; } +type AnswerDraft = { + value: FormAnswerValue; + imageCdnId: string | null; +}; + interface FormFillerClientProps { form: { id: string; title: string; description: string | null; isOpen: boolean; + allowMultipleSubmissions: boolean; }; questions: Question[]; - initialAnswers?: Record; + initialAnswers?: Record; hasSubmission?: boolean; + selectedSubmissionId?: string | null; + ownSubmissions?: { + id: string; + submittedAt: string; + answers: Record; + }[]; totalAnswers: number; queueNumber: number | null; preview?: boolean; @@ -115,6 +128,7 @@ type StoredDraft = { version: 1; answers: FormValues; otherValues: Record; + imageAnswers?: Record; }; const buildSchema = (questions: Question[]) => { @@ -133,16 +147,26 @@ const buildSchema = (questions: Question[]) => { return z.object(shape); }; +function isImageOnlyQuestion(question: Question) { + return ( + question.imageAnswerMode === "required" && + !question.required && + (question.type === "text" || question.type === "textarea") + ); +} + function prepareInitialState( questions: Question[], - answers: Record, + answers: Record, savedOtherValues: Record = {} ) { const values: FormValues = {}; + const imageAnswers: Record = {}; const otherValues = { ...savedOtherValues }; questions.forEach((question) => { - const answer = answers[question.id]; + const answer = answers[question.id]?.value; + imageAnswers[question.id] = answers[question.id]?.imageCdnId ?? null; if (question.type === "checkbox") { let selected = decodeCheckboxAnswer(answer); @@ -182,12 +206,13 @@ function prepareInitialState( values[question.id] = typeof answer === "string" ? answer : ""; }); - return { values, otherValues }; + return { values, otherValues, imageAnswers }; } function parseStoredDraft(raw: string): { answers: FormValues; otherValues: Record; + imageAnswers: Record; } { const parsed: unknown = JSON.parse(raw); if ( @@ -201,12 +226,14 @@ function parseStoredDraft(raw: string): { return { answers: draft.answers ?? {}, otherValues: draft.otherValues ?? {}, + imageAnswers: draft.imageAnswers ?? {}, }; } return { answers: (parsed as FormValues) ?? {}, otherValues: {}, + imageAnswers: {}, }; } @@ -215,12 +242,17 @@ export function FormFillerClient({ questions, initialAnswers = {}, hasSubmission = false, + selectedSubmissionId: initialSelectedSubmissionId = null, + ownSubmissions = [], totalAnswers: initialTotalAnswers, queueNumber: initialQueueNumber, preview = false, }: FormFillerClientProps) { const router = useRouter(); - const isUpdating = hasSubmission; + const [selectedSubmissionId, setSelectedSubmissionId] = useState( + initialSelectedSubmissionId + ); + const isUpdating = Boolean(selectedSubmissionId) || (!form.allowMultipleSubmissions && hasSubmission); const formSchema = useMemo(() => buildSchema(questions), [questions]); const serverInitialState = useMemo( @@ -231,6 +263,12 @@ export function FormFillerClient({ const [otherValues, setOtherValues] = useState>( serverInitialState.otherValues ); + const [imageAnswers, setImageAnswers] = useState>( + serverInitialState.imageAnswers + ); + const [imageErrors, setImageErrors] = useState>({}); + const imageInputRefs = useRef>({}); + const [uploadingAnswerId, setUploadingAnswerId] = useState(null); const [draftReady, setDraftReady] = useState(preview); const [draftSaved, setDraftSaved] = useState(false); const [showSuccessDialog, setShowSuccessDialog] = useState(false); @@ -270,11 +308,17 @@ export function FormFillerClient({ const draft = parseStoredDraft(raw); const restored = prepareInitialState( questions, - draft.answers, + Object.fromEntries( + Object.entries(draft.answers).map(([questionId, value]) => [ + questionId, + { value: value as FormAnswerValue, imageCdnId: draft.imageAnswers[questionId] ?? null }, + ]) + ), draft.otherValues ); reset(restored.values); setOtherValues(restored.otherValues); + setImageAnswers(restored.imageAnswers); setDraftSaved(true); } } catch { @@ -297,13 +341,94 @@ export function FormFillerClient({ version: 1, answers: getValues() as FormValues, otherValues, + imageAnswers, }; localStorage.setItem(LS_KEY(form.id), JSON.stringify(draft)); queueMicrotask(() => setDraftSaved(true)); } catch { queueMicrotask(() => setDraftSaved(false)); } - }, [draftReady, form.id, form.isOpen, getValues, otherValues, preview, watchedValues]); + }, [draftReady, form.id, form.isOpen, getValues, imageAnswers, otherValues, preview, watchedValues]); + + const switchToSubmission = (submissionId: string | null) => { + const submission = submissionId + ? ownSubmissions.find((item) => item.id === submissionId) + : null; + const nextState = prepareInitialState(questions, submission?.answers ?? {}); + reset(nextState.values); + setOtherValues(nextState.otherValues); + setImageAnswers(nextState.imageAnswers); + setImageErrors({}); + Object.values(imageInputRefs.current).forEach((input) => { + if (input) input.value = ""; + }); + setSelectedSubmissionId(submissionId); + setDraftSaved(false); + }; + + const clearAnswerImage = (questionId: string) => { + setImageAnswers((current) => ({ + ...current, + [questionId]: null, + })); + setImageErrors((current) => ({ + ...current, + [questionId]: null, + })); + const input = imageInputRefs.current[questionId]; + if (input) input.value = ""; + }; + + const validateRequiredImages = () => { + const nextErrors: Record = {}; + let firstInvalidId: string | null = null; + + for (const question of questions) { + if (question.imageAnswerMode === "required" && !imageAnswers[question.id]) { + nextErrors[question.id] = "จำเป็นต้องอัปโหลดรูปสำหรับข้อนี้"; + firstInvalidId ??= question.id; + } else { + nextErrors[question.id] = null; + } + } + + setImageErrors((current) => ({ ...current, ...nextErrors })); + if (firstInvalidId) { + document.getElementById(`answer-image-${firstInvalidId}`)?.focus(); + return false; + } + return true; + }; + + const handleAnswerImageUpload = async ( + questionId: string, + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append("file", file); + setUploadingAnswerId(questionId); + + try { + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + const data = await response.json(); + if (!response.ok || !data.id) { + throw new Error(data.error || "ไม่สามารถอัปโหลดรูปได้"); + } + setImageAnswers((current) => ({ ...current, [questionId]: data.id })); + setImageErrors((current) => ({ ...current, [questionId]: null })); + toast.success("อัปโหลดรูปแล้ว"); + } catch (error) { + toast.error(getErrorMessage(error, "ไม่สามารถอัปโหลดรูปได้")); + } finally { + setUploadingAnswerId(null); + } + }; const onSubmit = async (values: z.infer) => { if (preview) return; @@ -328,6 +453,10 @@ export function FormFillerClient({ } } + if (!validateRequiredImages()) { + return; + } + try { const formattedAnswers = questions.map((q) => { let val = values[q.id] as FormAnswerValue | undefined; @@ -342,10 +471,11 @@ export function FormFillerClient({ return { questionId: q.id, value: val ?? (q.type === "checkbox" ? [] : ""), + imageCdnId: q.imageAnswerMode === "none" ? null : imageAnswers[q.id] ?? null, }; }); - const res = await submitForm(form.id, formattedAnswers); + const res = await submitForm(form.id, formattedAnswers, selectedSubmissionId); if (res.ok) { setTotalAnswers(res.totalAnswers); setQueueNumber(res.queueNumber); @@ -362,15 +492,21 @@ export function FormFillerClient({ } }; + const onInvalidSubmit = () => { + validateRequiredImages(); + }; + const handleDelete = async () => { setIsDeleting(true); try { - const res = await deleteOwnSubmission(form.id); + const res = await deleteOwnSubmission(form.id, selectedSubmissionId); if (res.ok) { toast.success("ลบคำตอบแล้ว"); localStorage.removeItem(LS_KEY(form.id)); reset({}); setOtherValues({}); + setImageAnswers({}); + setSelectedSubmissionId(null); setDraftSaved(false); setDeleteDialogOpen(false); router.refresh(); @@ -405,7 +541,7 @@ export function FormFillerClient({ {questions.length} คำถาม {totalAnswers} คำตอบทั้งหมด {queueNumber !== null && ( - คิวของคุณ #{queueNumber} + ลำดับของคุณ #{queueNumber} )} @@ -439,8 +575,39 @@ export function FormFillerClient({ + {form.allowMultipleSubmissions && ownSubmissions.length > 0 && ( + + + คำตอบของคุณ + เลือกคำตอบเดิมเพื่อแก้ไข หรือเริ่มส่งคำตอบใหม่ + + + + {ownSubmissions.map((submission, index) => ( + + ))} + + + )} +
- + {questions.length === 0 ? ( @@ -519,7 +686,7 @@ export function FormFillerClient({ - {q.type === "text" && ( + {q.type === "text" && !isImageOnlyQuestion(q) && ( )} - {q.type === "textarea" && ( + {q.type === "textarea" && !isImageOnlyQuestion(q) && ( )} + {q.imageAnswerMode !== "none" && ( + + + รูปภาพคำตอบ + {q.imageAnswerMode === "required" && ( + จำเป็น + )} + {q.imageAnswerMode === "optional" && ( + ไม่บังคับ + )} + +
+ { + imageInputRefs.current[q.id] = input; + }} + type="file" + accept="image/png,image/jpeg,image/webp,image/gif" + disabled={!form.isOpen || isSubmitting || uploadingAnswerId !== null} + onChange={(event) => handleAnswerImageUpload(q.id, event)} + className="max-w-sm" + aria-invalid={Boolean(imageErrors[q.id])} + aria-describedby={ + imageErrors[q.id] + ? `answer-image-error-${q.id}` + : undefined + } + /> + {uploadingAnswerId === q.id && ( + + + กำลังอัปโหลด + + )} + {imageAnswers[q.id] && ( + + + + + + รูปภาพคำตอบ + { + event.currentTarget.style.display = "none"; + }} + /> + + + )} + {imageAnswers[q.id] && ( + + )} +
+ {imageErrors[q.id] && ( +

+ {imageErrors[q.id]} +

+ )} +
+ )}
diff --git a/app/form/page.tsx b/app/form/page.tsx index abcddac..68c4982 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -55,7 +55,7 @@ export default async function FormsListingPage() { {submissionStats.get(form.id)?.queueNumber != null && ( - คิวของคุณ #{submissionStats.get(form.id)?.queueNumber} + ลำดับของคุณ #{submissionStats.get(form.id)?.queueNumber} )} diff --git a/components/results-filters.tsx b/components/results-filters.tsx index 8ab3505..6fcda7b 100644 --- a/components/results-filters.tsx +++ b/components/results-filters.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -19,7 +19,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import type { ResultFilters } from "@/lib/admin-form-results"; -import { Download, Search, X, CalendarDays } from "lucide-react"; +import { Download, X, CalendarDays } from "lucide-react"; import { format, parseISO } from "date-fns"; function DateFilterPicker({ @@ -80,7 +80,7 @@ export function ResultsFilters({ const [values, setValues] = useState(filters); const base = `/admin/form/${formId}/result`; - const apply = () => { + const filterQuery = useMemo(() => { const params = new URLSearchParams(); if (values.q) params.set("q", values.q); if (values.sort !== "submittedAt") params.set("sort", values.sort); @@ -88,17 +88,31 @@ export function ResultsFilters({ if (values.edited !== "all") params.set("edited", values.edited); if (values.dateFrom) params.set("dateFrom", values.dateFrom); if (values.dateTo) params.set("dateTo", values.dateTo); - router.push(`${base}?${params}`); - }; + return params.toString(); + }, [values]); - const exportParams = new URLSearchParams({ - q: values.q, - sort: values.sort, - direction: values.direction, - edited: values.edited, - dateFrom: values.dateFrom, - dateTo: values.dateTo, - }); + const appliedQuery = useMemo(() => { + const params = new URLSearchParams(); + if (filters.q) params.set("q", filters.q); + if (filters.sort !== "submittedAt") params.set("sort", filters.sort); + if (filters.direction !== "desc") params.set("direction", filters.direction); + if (filters.edited !== "all") params.set("edited", filters.edited); + if (filters.dateFrom) params.set("dateFrom", filters.dateFrom); + if (filters.dateTo) params.set("dateTo", filters.dateTo); + return params.toString(); + }, [filters]); + + useEffect(() => { + if (filterQuery === appliedQuery) return; + + const timeout = window.setTimeout(() => { + router.replace(`${base}${filterQuery ? `?${filterQuery}` : ""}`, { + scroll: false, + }); + }, 350); + + return () => window.clearTimeout(timeout); + }, [appliedQuery, base, filterQuery, router]); return (
@@ -108,9 +122,6 @@ export function ResultsFilters({ onChange={(event) => setValues((current) => ({ ...current, q: event.target.value })) } - onKeyDown={(event) => { - if (event.key === "Enter") apply(); - }} placeholder="Search name or Discord ID" aria-label="Search responses" /> @@ -167,12 +178,8 @@ export function ResultsFilters({ setValues((current) => ({ ...current, dateTo })) } /> -