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
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
public/form-uploads
|
public/form
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user