feat: add navigation between submissions, enable image zoom, and automate cleanup of deleted form images
This commit is contained in:
+1
-1
@@ -40,4 +40,4 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
public/form-uploads
|
||||
public/form
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user