feat: add navigation between submissions, enable image zoom, and automate cleanup of deleted form images

This commit is contained in:
2026-05-28 19:51:55 +07:00 Unverified
parent 74993b66b2
commit 9693bef355
8 changed files with 87 additions and 22 deletions
+1 -1
View File
@@ -40,4 +40,4 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
public/form-uploads public/form
+29
View File
@@ -1,6 +1,8 @@
"use server"; "use server";
import { db } from "@/db"; import { db } from "@/db";
import { unlink } from "fs/promises";
import path from "path";
import { questions } from "@/db/schema"; import { questions } from "@/db/schema";
import type { QuestionType } from "@/db/schema"; import type { QuestionType } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -53,13 +55,40 @@ export async function updateQuestion(
}> }>
) { ) {
await assertAdmin(); await assertAdmin();
if (data.imageUrl !== undefined) {
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
});
if (q?.imageUrl && q.imageUrl !== data.imageUrl) {
await deleteImageFile(q.imageUrl);
}
}
await db.update(questions).set(data).where(eq(questions.id, id)); await db.update(questions).set(data).where(eq(questions.id, id));
revalidatePath("/form"); revalidatePath("/form");
revalidatePath("/form/admin"); revalidatePath("/form/admin");
} }
async function deleteImageFile(imageUrl: string | null) {
if (!imageUrl) return;
try {
const filename = imageUrl.split("/").pop();
if (filename) {
const filepath = path.join(process.cwd(), "public", "form", filename);
await unlink(filepath);
}
} catch (err) {
console.error("Failed to delete image file", err);
}
}
export async function deleteQuestion(id: string, formId: string) { export async function deleteQuestion(id: string, formId: string) {
await assertAdmin(); await assertAdmin();
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
});
if (q?.imageUrl) {
await deleteImageFile(q.imageUrl);
}
await db.delete(questions).where(eq(questions.id, id)); await db.delete(questions).where(eq(questions.id, id));
revalidatePath("/form"); revalidatePath("/form");
revalidatePath("/form/admin"); revalidatePath("/form/admin");
+2 -2
View File
@@ -22,11 +22,11 @@ export async function uploadImage(formData: FormData): Promise<string> {
const ext = file.name.split(".").pop() ?? "png"; const ext = file.name.split(".").pop() ?? "png";
const filename = `${crypto.randomUUID()}.${ext}`; const filename = `${crypto.randomUUID()}.${ext}`;
const uploadDir = path.join(process.cwd(), "public", "form-uploads"); const uploadDir = path.join(process.cwd(), "public", "form");
await mkdir(uploadDir, { recursive: true }); 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); await writeFile(path.join(uploadDir, filename), buffer);
return `/form-uploads/${filename}`; return `/form/${filename}`;
} }
+6 -5
View File
@@ -297,20 +297,21 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
<div className="space-y-2"> <div className="space-y-2">
{q.imageUrl ? ( {q.imageUrl ? (
<div className="relative inline-block border rounded-md p-1 bg-muted"> <div className="relative max-w-full h-auto inline-block shrink-0 group">
<Image <Image
fill width={200}
height={200}
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt={q.label}
className="max-h-20 rounded object-contain" className="w-32 h-auto rounded-md border object-contain"
/> />
<Button <Button
variant="destructive" variant="destructive"
size="icon" size="icon"
className="absolute -top-2 -right-2 h-6 w-6 rounded-full" className="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
onClick={() => handleRemoveImage(q.id)} onClick={() => handleRemoveImage(q.id)}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3 h-3" />
</Button> </Button>
</div> </div>
) : ( ) : (
+3 -2
View File
@@ -159,10 +159,11 @@ export default function SubmissionDetailClient({
{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 max-w-full h-auto max-h-60 overflow-hidden rounded-md border inline-block shrink-0">
<Image <Image
fill width={100}
height={100}
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt={q.label}
className="object-contain max-h-24 w-auto" className="object-contain w-auto"
/> />
</div> </div>
)} )}
+19
View File
@@ -36,6 +36,13 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
for (const ans of answersList) { for (const ans of answersList) {
answersMap[ans.questionId] = ans.value; answersMap[ans.questionId] = ans.value;
} }
const submissionsList = await db.query.submissions.findMany({
orderBy: (s, { desc }) => [desc(s.submittedAt)],
});
const currentIndex = submissionsList.findIndex((s) => s.id === id);
const prevId = currentIndex > 0 ? submissionsList[currentIndex - 1].id : null;
const nextId = currentIndex >= 0 && currentIndex < submissionsList.length - 1 ? submissionsList[currentIndex + 1].id : null;
return ( return (
<div className="space-y-6 max-w-2xl mx-auto"> <div className="space-y-6 max-w-2xl mx-auto">
@@ -48,8 +55,20 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
</Link> </Link>
<h1 className="text-3xl font-bold tracking-tight">Response Detail</h1> <h1 className="text-3xl font-bold tracking-tight">Response Detail</h1>
</div> </div>
<div className="flex items-center gap-2">
{prevId && (
<Link href={`/form/admin/result/${prevId}`}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{nextId && (
<Link href={`/form/admin/result/${nextId}`}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
<DeleteSubmissionButton id={submission.id} redirectUrl="/form/admin/result" /> <DeleteSubmissionButton id={submission.id} redirectUrl="/form/admin/result" />
</div> </div>
</div>
<SubmissionDetailClient <SubmissionDetailClient
submission={submission} submission={submission}
+21 -6
View File
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 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 { toast } from "sonner"; import { toast } from "sonner";
import { submitForm } from "@/app/actions/submissions"; import { submitForm } from "@/app/actions/submissions";
import { Loader2, LogOut } from "lucide-react"; import { Loader2, LogOut } from "lucide-react";
@@ -30,7 +31,7 @@ export function FormLayoutClient({
alt={userName} alt={userName}
width={28} width={28}
height={28} height={28}
className="rounded-full" className="rounded-full "
/> />
)} )}
<span className="text-sm text-muted-foreground">{userName}</span> <span className="text-sm text-muted-foreground">{userName}</span>
@@ -207,20 +208,34 @@ export function FormFillerClient({ form, questions, initialAnswers = {} }: FormF
<Card key={q.id} className="shadow-sm"> <Card key={q.id} className="shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<Label className="text-base font-semibold leading-normal"> <Label className="text-base font-semibold leading-normal mt-1">
{q.label} {q.label}
{q.required && <span className="text-destructive ml-1">*</span>} {q.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
</div>
{q.imageUrl && ( {q.imageUrl && (
<div className="mt-3 relative max-w-full h-auto max-h-80 overflow-hidden rounded-md border"> <Dialog>
<DialogTrigger asChild>
<div className="relative max-w-full h-auto overflow-hidden rounded-md border inline-block shrink-0 cursor-pointer hover:opacity-80 transition-opacity">
<Image
width={100}
height={100}
src={q.imageUrl}
alt={q.label}
className="object-contain w-auto"
/>
</div>
</DialogTrigger>
<DialogContent className="max-w-4xl border-0 bg-transparent p-0 shadow-none flex justify-center">
<DialogTitle className="sr-only">Enlarged image for {q.label}</DialogTitle>
<img <img
src={q.imageUrl} src={q.imageUrl}
alt={q.label} alt={q.label}
className="object-contain max-h-80 w-auto" className="max-h-[85vh] w-auto object-contain rounded-md"
/> />
</div> </DialogContent>
</Dialog>
)} )}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{q.type === "text" && ( {q.type === "text" && (
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB