feat: add allowOther field to questions and update image upload to return data URIs

This commit is contained in:
2026-05-30 01:35:33 +07:00 Unverified
parent 114804749c
commit 6a36965e8b
10 changed files with 689 additions and 27 deletions
+4
View File
@@ -37,6 +37,7 @@ export async function createQuestion(
required: data.required, required: data.required,
displayOrder: data.displayOrder, displayOrder: data.displayOrder,
options: data.options ?? [], options: data.options ?? [],
allowOther: false,
}); });
revalidatePath("/form"); revalidatePath("/form");
revalidatePath("/form/admin"); revalidatePath("/form/admin");
@@ -52,6 +53,7 @@ export async function updateQuestion(
displayOrder: number; displayOrder: number;
options: string[]; options: string[];
imageUrl: string | null; imageUrl: string | null;
allowOther: boolean;
}> }>
) { ) {
await assertAdmin(); await assertAdmin();
@@ -77,6 +79,7 @@ export async function bulkUpdateQuestions(
required: boolean; required: boolean;
displayOrder: number; displayOrder: number;
options: string[]; options: string[];
allowOther: boolean;
}[] }[]
) { ) {
await assertAdmin(); await assertAdmin();
@@ -92,6 +95,7 @@ export async function bulkUpdateQuestions(
required: u.required, required: u.required,
displayOrder: u.displayOrder, displayOrder: u.displayOrder,
options: u.options ?? [], options: u.options ?? [],
allowOther: u.allowOther,
}) })
.where(eq(questions.id, u.id)) .where(eq(questions.id, u.id))
) )
+4 -6
View File
@@ -36,14 +36,12 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Invalid image extension" }, { status: 400 }); return NextResponse.json({ error: "Invalid image extension" }, { status: 400 });
} }
const filename = `${crypto.randomUUID()}.${originalExt}`;
const uploadDir = path.join(process.cwd(), "public", "form");
await mkdir(uploadDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(path.join(uploadDir, filename), buffer); const base64 = buffer.toString("base64");
const mimeType = file.type;
const dataUri = `data:${mimeType};base64,${base64}`;
return NextResponse.json({ url: `/form/${filename}` }); return NextResponse.json({ url: dataUri });
} catch (error: any) { } catch (error: any) {
console.error("Upload error:", error); console.error("Upload error:", error);
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }); return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 });
+26 -9
View File
@@ -31,6 +31,7 @@ interface Question {
displayOrder: number; displayOrder: number;
required: boolean; required: boolean;
options: string[] | null; options: string[] | null;
allowOther: boolean;
} }
interface FormEditorClientProps { interface FormEditorClientProps {
@@ -102,6 +103,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formattedUpdates = questions.map(q => ({ const formattedUpdates = questions.map(q => ({
...q, ...q,
options: q.options === null ? [] : q.options, options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
})); }));
await bulkUpdateQuestions(form.id, formattedUpdates); await bulkUpdateQuestions(form.id, formattedUpdates);
toast.success("Questions saved!"); toast.success("Questions saved!");
@@ -338,6 +340,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
height={200} height={200}
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt={q.label}
unoptimized
className="w-32 h-auto rounded-md border object-contain" className="w-32 h-auto rounded-md border object-contain"
/> />
<Button <Button
@@ -410,15 +413,29 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
)} )}
<div className="flex justify-between items-center pt-2 border-t border-border"> <div className="flex justify-between items-center pt-2 border-t border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center flex-wrap gap-6">
<Switch <div className="flex items-center gap-2">
id={`required-${q.id}`} <Switch
checked={q.required} id={`required-${q.id}`}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { required: checked })} checked={q.required}
/> onCheckedChange={(checked) => handleUpdateQuestion(q.id, { required: checked })}
<Label htmlFor={`required-${q.id}`} className="text-sm font-normal text-muted-foreground"> />
Required <Label htmlFor={`required-${q.id}`} className="text-sm font-normal text-muted-foreground">
</Label> Required
</Label>
</div>
{(q.type === "radio" || q.type === "checkbox") && (
<div className="flex items-center gap-2">
<Switch
id={`allowOther-${q.id}`}
checked={q.allowOther}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { allowOther: checked })}
/>
<Label htmlFor={`allowOther-${q.id}`} className="text-sm font-normal text-muted-foreground">
Add 'Other' option
</Label>
</div>
)}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
+5 -5
View File
@@ -122,13 +122,13 @@ export default function SubmissionDetailClient({
)} )}
</div> </div>
{q.imageUrl && ( {q.imageUrl && (
<div className="relative max-w-full h-auto max-h-60 overflow-hidden rounded-md border inline-block shrink-0"> <div className="relative w-24 h-24 overflow-hidden rounded-md border shrink-0">
<Image <Image
width={100}
height={100}
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt="Question image"
className="object-contain w-auto" fill
unoptimized
className="object-cover"
/> />
</div> </div>
)} )}
+96 -6
View File
@@ -67,6 +67,7 @@ interface Question {
displayOrder: number; displayOrder: number;
required: boolean; required: boolean;
options: string[] | null; options: string[] | null;
allowOther: boolean;
} }
interface FormFillerClientProps { interface FormFillerClientProps {
@@ -105,7 +106,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const formSchema = useMemo(() => buildSchema(questions), [questions]); const formSchema = useMemo(() => buildSchema(questions), [questions]);
const defaultValues = useMemo(() => { const { defaultValues, initialOtherValues } = useMemo(() => {
const serverAnswers = { ...initialAnswers }; const serverAnswers = { ...initialAnswers };
let draft: Record<string, any> = {}; let draft: Record<string, any> = {};
try { try {
@@ -116,16 +117,43 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
const merged = { ...draft, ...serverAnswers }; const merged = { ...draft, ...serverAnswers };
const vals: Record<string, any> = {}; const vals: Record<string, any> = {};
const others: Record<string, string> = {};
questions.forEach(q => { questions.forEach(q => {
const ans = merged[q.id];
if (q.type === "checkbox") { if (q.type === "checkbox") {
vals[q.id] = merged[q.id] ? (Array.isArray(merged[q.id]) ? merged[q.id] : merged[q.id].split(", ").filter(Boolean)) : []; let arr = ans ? (Array.isArray(ans) ? ans : ans.split(", ").filter(Boolean)) : [];
if (q.allowOther && q.options) {
const standardOptions = q.options;
const customAnswers = arr.filter((x: string) => !standardOptions.includes(x) && x !== "__OTHER__");
if (customAnswers.length > 0) {
others[q.id] = customAnswers.join(", ");
}
// If the draft had __OTHER__ or if there's a custom answer from server, ensure __OTHER__ is selected
if (arr.includes("__OTHER__") || customAnswers.length > 0) {
arr = arr.filter((x: string) => standardOptions.includes(x));
arr.push("__OTHER__");
}
}
vals[q.id] = arr;
} else if (q.type === "radio") {
let val = ans || "";
if (q.allowOther && q.options && val !== "" && !q.options.includes(val)) {
if (val !== "__OTHER__") {
others[q.id] = val;
}
val = "__OTHER__";
}
vals[q.id] = val;
} else { } else {
vals[q.id] = merged[q.id] || ""; vals[q.id] = ans || "";
} }
}); });
return vals; return { defaultValues: vals, initialOtherValues: others };
}, [form.id, initialAnswers, questions]); }, [form.id, initialAnswers, questions]);
const [otherValues, setOtherValues] = useState<Record<string, string>>(initialOtherValues);
const methods = useForm<z.infer<typeof formSchema>>({ const methods = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues, defaultValues,
@@ -170,10 +198,22 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
try { try {
const formattedAnswers = questions.map((q) => { const formattedAnswers = questions.map((q) => {
const val = values[q.id] as string | string[] | undefined; let val = values[q.id] as string | string[] | undefined;
if (q.allowOther) {
const customText = otherValues[q.id] || "";
if (Array.isArray(val)) {
val = val.map(v => v === "__OTHER__" ? customText : v);
} else if (val === "__OTHER__") {
val = customText;
}
}
const finalString = Array.isArray(val) ? val.join(", ") : (val || "");
if (q.required && !finalString.trim()) {
throw new Error(`จำเป็นต้องตอบ "${q.label}" (สำหรับตัวเลือก 'อื่นๆ')`);
}
return { return {
questionId: q.id, questionId: q.id,
value: Array.isArray(val) ? val.join(", ") : (val || ""), value: finalString,
}; };
}); });
@@ -258,6 +298,7 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt={q.label}
fill fill
unoptimized
className="object-contain rounded-md" className="object-contain rounded-md"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
/> />
@@ -314,6 +355,25 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
</Label> </Label>
</div> </div>
))} ))}
{q.allowOther && (
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="__OTHER__" id={`${q.id}-other`} />
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
อื่นๆ
</Label>
</div>
{field.value === "__OTHER__" && (
<Input
className="ml-6 w-full max-w-xs"
value={otherValues[q.id] || ""}
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</div>
)}
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
)} )}
@@ -343,6 +403,36 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
</div> </div>
); );
})} })}
{q.allowOther && (
<div className="flex flex-col space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-other`}
checked={Array.isArray(field.value) && field.value.includes("__OTHER__")}
disabled={!form.isOpen || isSubmitting}
onCheckedChange={(checked) => {
const current = Array.isArray(field.value) ? field.value : [];
const updated = checked
? [...current, "__OTHER__"]
: current.filter(x => x !== "__OTHER__");
field.onChange(updated);
}}
/>
<Label htmlFor={`${q.id}-other`} className="font-normal cursor-pointer">
อื่นๆ
</Label>
</div>
{Array.isArray(field.value) && field.value.includes("__OTHER__") && (
<Input
className="ml-6 w-full max-w-xs"
value={otherValues[q.id] || ""}
onChange={(e) => setOtherValues(prev => ({...prev, [q.id]: e.target.value}))}
placeholder="โปรดระบุ"
disabled={!form.isOpen || isSubmitting}
/>
)}
</div>
)}
</div> </div>
</FormControl> </FormControl>
)} )}
+1
View File
@@ -35,6 +35,7 @@ export const questions = pgTable("question", {
displayOrder: integer("display_order").notNull().default(0), displayOrder: integer("display_order").notNull().default(0),
required: boolean("required").notNull().default(false), required: boolean("required").notNull().default(false),
options: jsonb("options").$type<string[]>().default([]), options: jsonb("options").$type<string[]>().default([]),
allowOther: boolean("allow_other").notNull().default(false),
}); });
export const submissions = pgTable("submission", { export const submissions = pgTable("submission", {
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "question" ADD COLUMN "allow_other" boolean DEFAULT false NOT NULL;
+544
View File
@@ -0,0 +1,544 @@
{
"id": "7ce7e225-2a92-45cb-8a26-72fa7d80ba64",
"prevId": "9272e7bb-ff66-4aa5-8022-21ea3a0afb8f",
"version": "7",
"dialect": "postgresql",
"tables": {
"account.account": {
"name": "account",
"schema": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"schemaTo": "account",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.session": {
"name": "session",
"schema": "account",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"schemaTo": "account",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.user": {
"name": "user",
"schema": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_id": {
"name": "discord_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"account.verificationToken": {
"name": "verificationToken",
"schema": "account",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.answer": {
"name": "answer",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"submission_id": {
"name": "submission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"question_id": {
"name": "question_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"answer_submission_id_submission_id_fk": {
"name": "answer_submission_id_submission_id_fk",
"tableFrom": "answer",
"tableTo": "submission",
"columnsFrom": [
"submission_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"answer_question_id_question_id_fk": {
"name": "answer_question_id_question_id_fk",
"tableFrom": "answer",
"tableTo": "question",
"columnsFrom": [
"question_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.form": {
"name": "form",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_open": {
"name": "is_open",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"discord_webhook_url": {
"name": "discord_webhook_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_webhook_template": {
"name": "discord_webhook_template",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discord_webhook_update_template": {
"name": "discord_webhook_update_template",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.question": {
"name": "question",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"form_id": {
"name": "form_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'text'"
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_order": {
"name": "display_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"required": {
"name": "required",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"options": {
"name": "options",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
},
"allow_other": {
"name": "allow_other",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"question_form_id_form_id_fk": {
"name": "question_form_id_form_id_fk",
"tableFrom": "question",
"tableTo": "form",
"columnsFrom": [
"form_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.submission": {
"name": "submission",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"form_id": {
"name": "form_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_discord_id": {
"name": "user_discord_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_name": {
"name": "user_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"submitted_at": {
"name": "submitted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"edit_history": {
"name": "edit_history",
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'[]'::jsonb"
}
},
"indexes": {},
"foreignKeys": {
"submission_form_id_form_id_fk": {
"name": "submission_form_id_form_id_fk",
"tableFrom": "submission",
"tableTo": "form",
"columnsFrom": [
"form_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {
"account": "account"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -43,6 +43,13 @@
"when": 1779969537666, "when": 1779969537666,
"tag": "0005_handy_sally_floyd", "tag": "0005_handy_sally_floyd",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780078238907,
"tag": "0006_concerned_mimic",
"breakpoints": true
} }
] ]
} }