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
next-env.d.ts
public/form-uploads
public/form
+29
View File
@@ -1,6 +1,8 @@
"use server";
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 { eq } from "drizzle-orm";
@@ -53,13 +55,40 @@ export async function updateQuestion(
}>
) {
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));
revalidatePath("/form");
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) {
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));
revalidatePath("/form");
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 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 });
const buffer = Buffer.from(await file.arrayBuffer());
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">
{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
fill
width={200}
height={200}
src={q.imageUrl}
alt={q.label}
className="max-h-20 rounded object-contain"
className="w-32 h-auto rounded-md border object-contain"
/>
<Button
variant="destructive"
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)}
>
<X className="w-3.5 h-3.5" />
<X className="w-3 h-3" />
</Button>
</div>
) : (
+3 -2
View File
@@ -159,10 +159,11 @@ export default function SubmissionDetailClient({
{q.imageUrl && (
<div className="relative max-w-full h-auto max-h-60 overflow-hidden rounded-md border inline-block shrink-0">
<Image
fill
width={100}
height={100}
src={q.imageUrl}
alt={q.label}
className="object-contain max-h-24 w-auto"
className="object-contain w-auto"
/>
</div>
)}
+20 -1
View File
@@ -36,6 +36,13 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
for (const ans of answersList) {
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 (
<div className="space-y-6 max-w-2xl mx-auto">
@@ -48,7 +55,19 @@ export default async function SubmissionDetailPage({ params }: SubmissionDetailP
</Link>
<h1 className="text-3xl font-bold tracking-tight">Response Detail</h1>
</div>
<DeleteSubmissionButton id={submission.id} redirectUrl="/form/admin/result" />
<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" />
</div>
</div>
<SubmissionDetailClient
+26 -11
View File
@@ -9,6 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
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 { toast } from "sonner";
import { submitForm } from "@/app/actions/submissions";
import { Loader2, LogOut } from "lucide-react";
@@ -30,7 +31,7 @@ export function FormLayoutClient({
alt={userName}
width={28}
height={28}
className="rounded-full"
className="rounded-full "
/>
)}
<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">
<CardHeader className="pb-3">
<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.required && <span className="text-destructive ml-1">*</span>}
</Label>
{q.imageUrl && (
<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
src={q.imageUrl}
alt={q.label}
className="max-h-[85vh] w-auto object-contain rounded-md"
/>
</DialogContent>
</Dialog>
)}
</div>
{q.imageUrl && (
<div className="mt-3 relative max-w-full h-auto max-h-80 overflow-hidden rounded-md border">
<img
src={q.imageUrl}
alt={q.label}
className="object-contain max-h-80 w-auto"
/>
</div>
)}
</CardHeader>
<CardContent>
{q.type === "text" && (
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB