feat: implement form submission system with Drizzle ORM and admin dashboard

This commit is contained in:
2026-05-28 17:46:31 +07:00 Unverified
parent ebfcef2bca
commit 49e7690b7b
39 changed files with 3263 additions and 33 deletions
+72
View File
@@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
SidebarFooter,
} from "@/components/ui/sidebar";
import { LayoutDashboard, ClipboardList, ArrowLeft } from "lucide-react";
export function AdminSidebar() {
const pathname = usePathname();
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader className="border-b border-border py-4 px-4 flex items-center justify-between">
<span className="font-bold tracking-wider text-primary text-sm uppercase">
Erika Admin
</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === "/form/admin"}>
<Link href="/form/admin">
<LayoutDashboard className="w-4 h-4" />
<span>Admin Dashboard</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith("/form/admin/result")}>
<Link href="/form/admin/result">
<ClipboardList className="w-4 h-4" />
<span>Result Page</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/form">
<ArrowLeft className="w-4 h-4" />
<span>Exit Admin</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2 } from "lucide-react";
import { deleteForm } from "@/app/actions/form";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
interface DeleteFormButtonProps {
formId: string;
}
export default function DeleteFormButton({ formId }: DeleteFormButtonProps) {
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
setDeleting(true);
try {
await deleteForm(formId);
toast.success("Form deleted successfully!");
setOpen(false);
} catch (err: any) {
toast.error(err.message || "Failed to delete form.");
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10">
<Trash2 className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the form, all of its questions, and all submissions/answers.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" disabled={deleting} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button variant="destructive" disabled={deleting} onClick={handleDelete}>
{deleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+387
View File
@@ -0,0 +1,387 @@
"use client";
import React, { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import {
createQuestion,
updateQuestion,
deleteQuestion,
reorderQuestions,
} from "@/app/actions/questions";
import { uploadImage } from "@/app/actions/upload";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
interface Question {
id: string;
formId: string;
type: "text" | "textarea" | "radio" | "checkbox";
label: string;
imageUrl: string | null;
displayOrder: number;
required: boolean;
options: string[] | null;
}
interface FormEditorClientProps {
form: {
id: string;
title: string;
description: string | null;
};
initialQuestions: Question[];
}
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
const router = useRouter();
const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || "");
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null);
const [isFormSaving, startFormSaveTransition] = useTransition();
const saveFormMetadata = () => {
if (!formTitle.trim()) {
toast.error("Form title is required.");
return;
}
startFormSaveTransition(async () => {
try {
await updateForm(form.id, {
title: formTitle,
description: formDescription,
});
toast.success("Form metadata saved!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to update form.");
}
});
};
const handleAddQuestion = async () => {
try {
const displayOrder = questions.length;
await createQuestion(form.id, {
type: "text",
label: `Untitled Question ${questions.length + 1}`,
required: false,
displayOrder,
options: [],
});
toast.success("Question added!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to add question.");
}
};
const handleUpdateQuestion = async (id: string, updates: Partial<Question>) => {
try {
setQuestions((prev) =>
prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q))
);
const formattedUpdates = {
...updates,
options: updates.options === null ? [] : updates.options,
};
await updateQuestion(id, form.id, formattedUpdates);
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to update question.");
}
};
const handleDeleteQuestion = async (id: string) => {
try {
await deleteQuestion(id, form.id);
toast.success("Question deleted!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to delete question.");
}
};
const handleMove = async (index: number, direction: "up" | "down") => {
const newQuestions = [...questions];
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newQuestions.length) return;
const temp = newQuestions[index];
newQuestions[index] = newQuestions[targetIndex];
newQuestions[targetIndex] = temp;
setQuestions(newQuestions);
try {
await reorderQuestions(form.id, newQuestions.map((q) => q.id));
toast.success("Order updated!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to reorder questions.");
}
};
const handleImageUpload = async (questionId: string, e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
setUploadingId(questionId);
try {
const imageUrl = await uploadImage(formData);
await handleUpdateQuestion(questionId, { imageUrl });
toast.success("Image uploaded!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to upload image.");
} finally {
setUploadingId(null);
}
};
const handleRemoveImage = async (questionId: string) => {
await handleUpdateQuestion(questionId, { imageUrl: null });
toast.success("Image removed.");
router.refresh();
};
const handleAddOption = async (question: Question) => {
const options = question.options ? [...question.options] : [];
options.push(`Option ${options.length + 1}`);
await handleUpdateQuestion(question.id, { options });
};
const handleUpdateOption = async (question: Question, optIndex: number, val: string) => {
if (!question.options) return;
const options = [...question.options];
options[optIndex] = val;
await handleUpdateQuestion(question.id, { options });
};
const handleRemoveOption = async (question: Question, optIndex: number) => {
if (!question.options) return;
const options = question.options.filter((_, idx) => idx !== optIndex);
await handleUpdateQuestion(question.id, { options });
};
React.useEffect(() => {
setQuestions(initialQuestions);
}, [initialQuestions]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>Configure the main title and description of your form.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="form-title">Form Title</Label>
<Input
id="form-title"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="e.g. Untitled Form"
/>
</div>
<div className="space-y-2">
<Label htmlFor="form-description">Description</Label>
<Textarea
id="form-description"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Provide a description..."
className="min-h-20"
/>
</div>
<div className="flex justify-end pt-2">
<Button onClick={saveFormMetadata} disabled={isFormSaving} className="gap-2">
{isFormSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Settings
</Button>
</div>
</CardContent>
</Card>
<Separator />
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">Questions</h2>
<Button onClick={handleAddQuestion} className="gap-2">
<Plus className="w-4 h-4" />
Add Question
</Button>
</div>
<div className="space-y-4">
{questions.map((q, index) => (
<Card key={q.id} className="relative">
<CardContent className="pt-6 space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1 w-full">
<Input
value={q.label}
onChange={(e) => handleUpdateQuestion(q.id, { label: e.target.value })}
placeholder="Question Text"
className="font-medium text-base w-full"
/>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Select
value={q.type}
onValueChange={(val: any) => handleUpdateQuestion(q.id, { type: val })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Short Answer</SelectItem>
<SelectItem value="textarea">Paragraph</SelectItem>
<SelectItem value="radio">Multiple Choice</SelectItem>
<SelectItem value="checkbox">Checkboxes</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-1 border rounded-md p-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => handleMove(index, "up")}
>
<ArrowUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === questions.length - 1}
onClick={() => handleMove(index, "down")}
>
<ArrowDown className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="space-y-2">
{q.imageUrl ? (
<div className="relative inline-block border rounded-md p-1 bg-muted">
<img
src={q.imageUrl}
alt={q.label}
className="max-h-40 rounded object-contain"
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 h-6 w-6 rounded-full"
onClick={() => handleRemoveImage(q.id)}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Label
htmlFor={`img-upload-${q.id}`}
className="flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-accent text-sm text-muted-foreground"
>
{uploadingId === q.id ? (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
) : (
<ImageIcon className="w-4 h-4 text-primary" />
)}
<span>Add Image</span>
</Label>
<input
id={`img-upload-${q.id}`}
type="file"
accept="image/*"
onChange={(e) => handleImageUpload(q.id, e)}
className="hidden"
disabled={uploadingId !== null}
/>
</div>
)}
</div>
{(q.type === "radio" || q.type === "checkbox") && (
<div className="space-y-2 pl-4 border-l-2 border-primary/20">
<Label className="text-sm font-semibold">Options</Label>
<div className="space-y-2">
{(q.options || []).map((opt, optIdx) => (
<div key={optIdx} className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
<Input
value={opt}
onChange={(e) => handleUpdateOption(q, optIdx, e.target.value)}
className="h-8 max-w-sm"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => handleRemoveOption(q, optIdx)}
>
<Trash className="w-4 h-4" />
</Button>
</div>
))}
<Button
variant="ghost"
size="sm"
onClick={() => handleAddOption(q)}
className="text-primary hover:text-primary/80 h-8 gap-1 pl-1"
>
<Plus className="w-4 h-4" />
Add Option
</Button>
</div>
</div>
)}
<div className="flex justify-between items-center pt-2 border-t border-border">
<div className="flex items-center space-x-2">
<Switch
id={`required-${q.id}`}
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>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteQuestion(q.id)}
className="text-destructive hover:bg-destructive/10"
>
<Trash className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar } from "./admin-sidebar";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const discordId = (session?.user as any)?.discordId;
if (!discordId || !adminIds.includes(discordId)) {
redirect("/form");
}
return (
<SidebarProvider>
<div className="flex min-h-screen w-full bg-background text-foreground dark">
<AdminSidebar />
<main className="flex-1 p-6 md:p-8 overflow-y-auto max-w-6xl mx-auto">
{children}
</main>
</div>
</SidebarProvider>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { db } from "@/db";
import { forms } from "@/db/schema";
import FormEditorClient from "./form-editor-client";
export const dynamic = "force-dynamic";
export default async function AdminDashboardPage() {
let form = await db.query.forms.findFirst();
if (!form) {
const [newForm] = await db
.insert(forms)
.values({
title: "Erika Custom Form",
description: "Welcome! Please fill out the form below.",
})
.returning();
form = newForm;
}
const questionsList = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, form.id),
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
return (
<div className="space-y-6 max-w-4xl mx-auto pb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground mt-1">
Customize form settings, add/edit questions, or upload images.
</p>
</div>
</div>
<FormEditorClient form={form} initialQuestions={questionsList} />
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { db } from "@/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import SubmissionDetailClient from "./submission-detail-client";
import { DeleteSubmissionButton } from "../delete-buttons-client";
interface SubmissionDetailPageProps {
params: Promise<{
id: string;
}>;
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id } = await params;
const submission = await db.query.submissions.findFirst({
where: (s, { eq }) => eq(s.id, id),
});
if (!submission) {
notFound();
}
const questions = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, submission.formId),
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
const answersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, submission.id),
});
const answersMap: Record<string, string> = {};
for (const ans of answersList) {
answersMap[ans.questionId] = ans.value;
}
return (
<div className="space-y-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/form/admin/result">
<Button variant="ghost" size="icon">
<ArrowLeft className="w-4 h-4" />
</Button>
</Link>
<h1 className="text-3xl font-bold tracking-tight">Response Detail</h1>
</div>
<DeleteSubmissionButton id={submission.id} redirectUrl="/form/admin/result" />
</div>
<SubmissionDetailClient
submission={submission}
questions={questions}
answers={answersMap}
/>
</div>
);
}
@@ -0,0 +1,175 @@
"use client";
import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Copy, Check, Users, Calendar } from "lucide-react";
interface Question {
id: string;
label: string;
type: string;
imageUrl: string | null;
}
interface SubmissionDetailClientProps {
submission: {
id: string;
userName: string | null;
userDiscordId: string | null;
submittedAt: Date;
editHistory?: { editedAt: string; oldAnswers: Record<string, string> }[] | null;
};
questions: Question[];
answers: Record<string, string>;
}
export default function SubmissionDetailClient({
submission,
questions,
answers,
}: SubmissionDetailClientProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopy = (text: string, id: string) => {
if (!text) return;
navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success("Answer copied to clipboard!");
setTimeout(() => setCopiedId(null), 2000);
};
const userLabel = submission.userName
? `${submission.userName} (${submission.userDiscordId || "N/A"})`
: submission.userDiscordId || "Anonymous";
const editHistory = Array.isArray(submission.editHistory) ? submission.editHistory : [];
return (
<div className="space-y-6">
{/* Submission Metadata Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-xl">Submission Info</CardTitle>
<CardDescription>Details about this respondent.</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="w-4 h-4 shrink-0" />
<span className="font-semibold text-foreground">Respondent:</span>
<span>{userLabel}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4 shrink-0" />
<span className="font-semibold text-foreground">Initial Submission:</span>
<span>{new Date(submission.submittedAt).toLocaleString()}</span>
</div>
{editHistory.length > 0 && (
<div className="flex flex-col mt-2 pt-2 border-t">
<span className="font-semibold text-foreground mb-1">Edit History:</span>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{editHistory.map((edit, idx) => (
<li key={idx}>Edited at {new Date(edit.editedAt).toLocaleString()}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
const ansValue = answers[q.id] || "";
// Check if this specific question was edited by comparing current answer with old ones
let wasEdited = false;
let oldAnswerForQ = "";
let lastEditedTime = "";
if (editHistory.length > 0) {
// Find the most recent edit where the answer was different
for (let i = editHistory.length - 1; i >= 0; i--) {
const oldAns = editHistory[i].oldAnswers[q.id] || "";
if (oldAns !== ansValue) {
wasEdited = true;
oldAnswerForQ = oldAns;
lastEditedTime = new Date(editHistory[i].editedAt).toLocaleString();
break;
}
}
}
return (
<Card key={q.id}>
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 gap-4">
<div className="space-y-1">
<Label className="text-base font-semibold leading-normal">
{q.label}
</Label>
{wasEdited && (
<p className="text-xs text-muted-foreground italic">
This section has been edited (last edit: {lastEditedTime})
</p>
)}
</div>
{q.imageUrl && (
<div className="relative max-w-full h-auto max-h-60 overflow-hidden rounded-md border inline-block shrink-0">
<img
src={q.imageUrl}
alt={q.label}
className="object-contain max-h-24 w-auto"
/>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Current Answer */}
{ansValue ? (
<div className="flex items-start justify-between gap-4 p-3 bg-muted rounded-md group">
<div className="flex flex-col">
{wasEdited && <span className="text-xs font-semibold text-muted-foreground mb-1">Current Answer:</span>}
<span className="text-sm whitespace-pre-wrap leading-relaxed">
{ansValue}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleCopy(ansValue, q.id)}
>
{copiedId === q.id ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div>
) : (
<span className="text-sm text-muted-foreground italic pl-3 border-l-2 border-muted block">
No response provided
</span>
)}
{/* Previous Answer if edited */}
{wasEdited && (
<div className="flex items-start justify-between gap-4 p-3 bg-muted/50 rounded-md border border-dashed border-muted-foreground/30">
<div className="flex flex-col">
<span className="text-xs font-semibold text-muted-foreground mb-1">Previous Answer:</span>
<span className="text-sm whitespace-pre-wrap leading-relaxed text-muted-foreground opacity-80">
{oldAnswerForQ || <span className="italic">No previous response</span>}
</span>
</div>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2 } from "lucide-react";
import { deleteSubmission, deleteAllSubmissions } from "@/app/actions/submissions";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
export function DeleteSubmissionButton({ id, redirectUrl }: { id: string, redirectUrl?: string }) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleDelete = async () => {
setLoading(true);
try {
await deleteSubmission(id);
toast.success("Submission deleted");
if (redirectUrl) {
router.push(redirectUrl);
}
} catch (err: any) {
toast.error(err.message || "Failed to delete submission");
} finally {
setLoading(false);
}
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
{loading ? <Spinner /> : <Trash2 className="w-3.5 h-3.5" />}
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete this submission and remove its data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function DeleteAllSubmissionsButton() {
const [loading, setLoading] = useState(false);
const handleDeleteAll = async () => {
setLoading(true);
try {
await deleteAllSubmissions();
toast.success("All submissions deleted");
} catch (err: any) {
toast.error(err.message || "Failed to delete submissions");
} finally {
setLoading(false);
}
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={loading}>
{loading ? <Spinner /> : <Trash2 className="w-4 h-4" />}
Delete All Submissions
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete <strong>ALL</strong> submissions and remove their data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll}>
Delete All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { db } from "@/db";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Eye, Users } from "lucide-react";
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "./delete-buttons-client";
export const dynamic = "force-dynamic";
export default async function ResultsListPage() {
const submissionsList = await db.query.submissions.findMany({
orderBy: (s, { desc }) => [desc(s.submittedAt)],
});
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Form Submissions</h1>
<p className="text-muted-foreground mt-1">
Review all submissions and individual user answers.
</p>
</div>
{submissionsList.length > 0 && <DeleteAllSubmissionsButton />}
</div>
{submissionsList.length === 0 ? (
<Card className="border-dashed border-2 py-12 text-center">
<CardHeader>
<CardTitle>No submissions yet</CardTitle>
<CardDescription>
When users fill out your form, their responses will appear here.
</CardDescription>
</CardHeader>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Submissions</CardTitle>
<CardDescription>
Total {submissionsList.length} responses collected. Click view to inspect individual answers.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>User (Discord)</TableHead>
<TableHead>Submitted At</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{submissionsList.map((sub) => {
const userLabel = sub.userName
? `${sub.userName} (${sub.userDiscordId || "N/A"})`
: sub.userDiscordId || "Anonymous";
return (
<TableRow key={sub.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{userLabel}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(sub.submittedAt).toLocaleString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/form/admin/result/${sub.id}`}>
<Button size="sm" variant="outline" className="gap-1">
<Eye className="w-3.5 h-3.5" />
View Answers
</Button>
</Link>
<DeleteSubmissionButton id={sub.id} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { toast } from "sonner";
import { submitForm } from "@/app/actions/submissions";
import { Loader2 } from "lucide-react";
interface Question {
id: string;
formId: string;
type: "text" | "textarea" | "radio" | "checkbox";
label: string;
imageUrl: string | null;
displayOrder: number;
required: boolean;
options: string[] | null;
}
interface FormFillerClientProps {
form: {
id: string;
title: string;
description: string | null;
};
questions: Question[];
initialAnswers?: Record<string, string>;
}
export default function FormFillerClient({ form, questions, initialAnswers = {} }: FormFillerClientProps) {
const router = useRouter();
// Pre-process checkbox answers since they are stored as comma-separated strings
const initialCheckboxes: Record<string, string[]> = {};
for (const q of questions) {
if (q.type === "checkbox" && initialAnswers[q.id]) {
initialCheckboxes[q.id] = initialAnswers[q.id].split(", ");
}
}
const [answers, setAnswers] = useState<Record<string, string>>(initialAnswers);
const [checkboxAnswers, setCheckboxAnswers] = useState<Record<string, string[]>>(initialCheckboxes);
const [submitting, setSubmitting] = useState(false);
const isUpdating = Object.keys(initialAnswers).length > 0;
const handleTextChange = (questionId: string, value: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
};
const handleRadioChange = (questionId: string, value: string) => {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
};
const handleCheckboxChange = (questionId: string, option: string, checked: boolean) => {
setCheckboxAnswers((prev) => {
const current = prev[questionId] || [];
const updated = checked
? [...current, option]
: current.filter((item) => item !== option);
return { ...prev, [questionId]: updated };
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate required questions
for (const q of questions) {
if (q.required) {
if (q.type === "checkbox") {
const selected = checkboxAnswers[q.id] || [];
if (selected.length === 0) {
toast.error(`"${q.label}" is required.`);
return;
}
} else {
const ans = answers[q.id] || "";
if (!ans.trim()) {
toast.error(`"${q.label}" is required.`);
return;
}
}
}
}
setSubmitting(true);
try {
const formattedAnswers = questions.map((q) => {
let value = "";
if (q.type === "checkbox") {
value = (checkboxAnswers[q.id] || []).join(", ");
} else {
value = answers[q.id] || "";
}
return {
questionId: q.id,
value,
};
});
const res = await submitForm(form.id, formattedAnswers);
if (res.ok) {
toast.success(isUpdating ? "Form updated successfully!" : "Form submitted successfully!");
// We don't reset answers if updating, so they can see their current state
if (!isUpdating) {
setAnswers({});
setCheckboxAnswers({});
}
}
} catch (err: any) {
console.error(err);
toast.error(err.message || "Failed to submit form.");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<Card className="border-t-8 border-t-primary shadow-md">
<CardHeader>
<CardTitle className="text-3xl font-bold">{form.title}</CardTitle>
{form.description && (
<CardDescription className="text-base mt-2 whitespace-pre-wrap">
{form.description}
</CardDescription>
)}
</CardHeader>
</Card>
<div className="space-y-4">
{questions.map((q) => (
<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">
{q.label}
{q.required && <span className="text-destructive ml-1">*</span>}
</Label>
</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" && (
<Input
type="text"
placeholder="Your answer"
required={q.required}
value={answers[q.id] || ""}
onChange={(e) => handleTextChange(q.id, e.target.value)}
className="w-full"
/>
)}
{q.type === "textarea" && (
<Textarea
placeholder="Your answer"
required={q.required}
value={answers[q.id] || ""}
onChange={(e) => handleTextChange(q.id, e.target.value)}
className="w-full min-h-24 resize-y"
/>
)}
{q.type === "radio" && q.options && (
<RadioGroup
required={q.required}
value={answers[q.id] || ""}
onValueChange={(val) => handleRadioChange(q.id, val)}
className="space-y-2"
>
{q.options.map((opt, idx) => (
<div key={idx} className="flex items-center space-x-2">
<RadioGroupItem value={opt} id={`${q.id}-${idx}`} />
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
{opt}
</Label>
</div>
))}
</RadioGroup>
)}
{q.type === "checkbox" && q.options && (
<div className="space-y-2">
{q.options.map((opt, idx) => {
const isChecked = (checkboxAnswers[q.id] || []).includes(opt);
return (
<div key={idx} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${idx}`}
checked={isChecked}
onCheckedChange={(checked) =>
handleCheckboxChange(q.id, opt, !!checked)
}
/>
<Label htmlFor={`${q.id}-${idx}`} className="font-normal cursor-pointer">
{opt}
</Label>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end items-center pt-4">
<Button type="submit" disabled={submitting} className="min-w-28">
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting
</>
) : isUpdating ? (
"Update Submission"
) : (
"Submit"
)}
</Button>
</div>
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { LogOut } from "lucide-react";
export default function FormLayoutClient({
userName,
userImage,
}: {
userName: string;
userImage: string;
}) {
return (
<div className="flex items-center gap-3">
{userImage && (
<Image
src={userImage}
alt={userName}
width={28}
height={28}
className="rounded-full"
/>
)}
<span className="text-sm text-muted-foreground">{userName}</span>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
>
<LogOut className="w-4 h-4" />
</Button>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { Toaster } from "@/components/ui/sonner";
import FormLayoutClient from "./layout-client";
import LoginDialog from "./login-dialog";
export default async function FormLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
return (
<div className="min-h-screen flex flex-col">
<header className="border-b border-border px-6 py-3 flex items-center justify-between">
<span className="font-semibold text-sm text-muted-foreground tracking-wide uppercase">
Forms
</span>
{session?.user && (
<FormLayoutClient
userName={session.user.name ?? ""}
userImage={session.user.image ?? ""}
/>
)}
</header>
<main className="flex-1">{children}</main>
<Toaster />
{!session && <LoginDialog />}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { signIn } from "next-auth/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export default function LoginDialog() {
return (
<Dialog open>
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle className="text-xl font-bold">Login Required</DialogTitle>
<DialogDescription className="text-base mt-1">
You need to login to continue.
</DialogDescription>
</DialogHeader>
<Button
className="w-full gap-2 mt-2"
onClick={() => signIn("discord", { callbackUrl: window.location.href })}
>
<svg
className="w-5 h-5"
viewBox="0 0 127.14 96.36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" />
</svg>
Continue with Discord
</Button>
</DialogContent>
</Dialog>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { db } from "@/db";
import { forms } from "@/db/schema";
import FormFillerClient from "./form-filler-client";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export const dynamic = "force-dynamic";
export default async function FormsSubmitPage() {
const session = await getServerSession(authOptions);
const discordId = (session?.user as { discordId?: string })?.discordId;
// Automatically retrieve the first form, or create a default one
let form = await db.query.forms.findFirst();
if (!form) {
const [newForm] = await db
.insert(forms)
.values({
title: "Erika Custom Form",
description: "Welcome! Please fill out the form below.",
})
.returning();
form = newForm;
}
const questionsList = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, form.id),
orderBy: (q, { asc }) => [asc(q.displayOrder)],
});
let initialAnswers: Record<string, string> = {};
if (discordId) {
const existingSubmission = await db.query.submissions.findFirst({
where: (s, { eq, and }) =>
and(eq(s.formId, form.id), eq(s.userDiscordId, discordId)),
});
if (existingSubmission) {
const answersList = await db.query.answers.findMany({
where: (a, { eq }) => eq(a.submissionId, existingSubmission.id),
});
for (const a of answersList) {
initialAnswers[a.questionId] = a.value;
}
}
}
return (
<div className="max-w-2xl mx-auto p-4 md:p-6 space-y-6">
<FormFillerClient
form={form}
questions={questionsList}
initialAnswers={initialAnswers}
/>
</div>
);
}