feat: implement form submission system with Drizzle ORM and admin dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user