refactor: migrate administrative form management to a centralized modular routing structure

This commit is contained in:
2026-06-29 18:41:53 +07:00 Unverified
parent 1d6b002360
commit 3a257e4758
28 changed files with 1774 additions and 221 deletions
+673
View File
@@ -0,0 +1,673 @@
"use client";
import React, { useState, useTransition, useRef, useCallback, useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import {
createQuestion,
updateQuestion,
deleteQuestion,
bulkUpdateQuestions,
} from "@/app/actions/questions";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
SidebarFooter,
} from "@/components/ui/sidebar";
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X, Check, LayoutDashboard, ClipboardList, ArrowLeft, Webhook } from "lucide-react";
import Image from 'next/image';
import { RichTextEditor } from "@/components/rich-text-editor";
import { FormattedText } from "@/components/formatted-text";
interface Question {
id: string;
formId: string;
type: "text" | "textarea" | "radio" | "checkbox";
label: string;
imageUrl: string | null;
displayOrder: number;
required: boolean;
options: string[] | null;
allowOther: boolean;
}
interface FormEditorClientProps {
form: {
id: string;
title: string;
description: string | null;
isOpen: boolean;
};
initialQuestions: Question[];
}
type AutoSaveStatus = "idle" | "saving" | "saved";
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
const router = useRouter();
const [formTitle, setFormTitle] = useState(form.title);
const [formDescription, setFormDescription] = useState(form.description || "");
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
const [uploadingId, setUploadingId] = useState<string | null>(null);
const [formSaveStatus, setFormSaveStatus] = useState<AutoSaveStatus>("idle");
const [questionsSaveStatus, setQuestionsSaveStatus] = useState<AutoSaveStatus>("idle");
const formTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const questionsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Refs to always read latest state inside debounced callbacks
const formTitleRef = useRef(formTitle);
const formDescriptionRef = useRef(formDescription);
const formIsOpenRef = useRef(formIsOpen);
const questionsRef = useRef(questions);
formTitleRef.current = formTitle;
formDescriptionRef.current = formDescription;
formIsOpenRef.current = formIsOpen;
questionsRef.current = questions;
const autoSaveForm = useCallback(() => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
setFormSaveStatus("idle");
formTimerRef.current = setTimeout(async () => {
const title = formTitleRef.current;
if (!title.trim()) return;
setFormSaveStatus("saving");
try {
await updateForm(form.id, {
title,
description: formDescriptionRef.current,
isOpen: formIsOpenRef.current,
});
setFormSaveStatus("saved");
router.refresh();
setTimeout(() => setFormSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save form settings.");
setFormSaveStatus("idle");
}
}, 1000);
}, [form.id, router]);
const autoSaveQuestions = useCallback(() => {
if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current);
setQuestionsSaveStatus("idle");
questionsTimerRef.current = setTimeout(async () => {
const current = questionsRef.current;
if (current.length === 0) return;
setQuestionsSaveStatus("saving");
try {
const formattedUpdates = current.map(q => ({
...q,
options: q.options === null ? [] : q.options,
allowOther: q.allowOther,
}));
await bulkUpdateQuestions(form.id, formattedUpdates);
setQuestionsSaveStatus("saved");
router.refresh();
setTimeout(() => setQuestionsSaveStatus("idle"), 2000);
} catch {
toast.error("Failed to auto-save questions.");
setQuestionsSaveStatus("idle");
}
}, 1500);
}, [form.id, router]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
if (questionsTimerRef.current) clearTimeout(questionsTimerRef.current);
};
}, []);
const handleFormTitleChange = (val: string) => {
setFormTitle(val);
autoSaveForm();
};
const handleFormDescriptionChange = (val: string) => {
setFormDescription(val);
autoSaveForm();
};
const handleFormIsOpenChange = (val: boolean) => {
setFormIsOpen(val);
autoSaveForm();
};
const handleAddQuestion = async () => {
try {
const displayOrder = questions.length;
await createQuestion(form.id, {
type: "text",
label: `Untitled Question ${questions.length + 1}`,
required: false,
displayOrder,
options: [],
});
router.refresh();
} catch (err: any) {
console.error("Failed to add question:", err);
}
};
const handleUpdateQuestion = (id: string, updates: Partial<Question>) => {
setQuestions((prev) =>
prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q))
);
autoSaveQuestions();
};
const handleDeleteQuestion = async (id: string) => {
try {
await deleteQuestion(id, form.id);
router.refresh();
} catch (err: any) {
console.error("Failed to delete question:", err);
}
};
const handleMove = (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;
// Update display orders based on new positions
newQuestions.forEach((q, idx) => {
q.displayOrder = idx;
});
setQuestions(newQuestions);
autoSaveQuestions();
};
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);
toast.promise(
new Promise<string>(async (resolve, reject) => {
try {
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to upload image");
resolve(data.url);
} catch (error) {
reject(error);
}
}).then(async (imageUrl) => {
await updateQuestion(questionId, form.id, { imageUrl });
setQuestions((prev) =>
prev.map((q) => (q.id === questionId ? { ...q, imageUrl } : q))
);
router.refresh();
}).finally(() => {
setUploadingId(null);
}),
{
loading: "Uploading image...",
success: "Image uploaded!",
error: (err: any) => err.message || "Failed to upload image.",
}
);
};
const handleRemoveImage = async (questionId: string) => {
toast.promise(
updateQuestion(questionId, form.id, { imageUrl: null }).then(() => {
setQuestions((prev) =>
prev.map((q) => (q.id === questionId ? { ...q, imageUrl: null } : q))
);
router.refresh();
}),
{
loading: "Removing image...",
success: "Image removed.",
error: (err: any) => err.message || "Failed to remove image.",
}
);
};
const handleAddOption = (question: Question) => {
const options = question.options ? [...question.options] : [];
options.push(`Option ${options.length + 1}`);
handleUpdateQuestion(question.id, { options });
};
const handleUpdateOption = (question: Question, optIndex: number, val: string) => {
if (!question.options) return;
const options = [...question.options];
options[optIndex] = val;
handleUpdateQuestion(question.id, { options });
};
const handleRemoveOption = (question: Question, optIndex: number) => {
if (!question.options) return;
const options = question.options.filter((_, idx) => idx !== optIndex);
handleUpdateQuestion(question.id, { options });
};
React.useEffect(() => {
setQuestions(initialQuestions);
}, [initialQuestions]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Form Settings</CardTitle>
<CardDescription>Configure the main title and description of your form.</CardDescription>
</div>
<SaveStatusIndicator status={formSaveStatus} />
</div>
</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) => handleFormTitleChange(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) => handleFormDescriptionChange(e.target.value)}
placeholder="Provide a description..."
className="min-h-20"
/>
</div>
<div className="flex items-center space-x-2 pt-2">
<Switch
id="form-is-open"
checked={formIsOpen}
onCheckedChange={handleFormIsOpenChange}
/>
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
Accepting Submissions
</Label>
</div>
</CardContent>
</Card>
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold">Questions</h2>
<SaveStatusIndicator status={questionsSaveStatus} />
</div>
<Button onClick={handleAddQuestion} variant="outline" 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">
<RichTextEditor
value={q.label}
onChange={(html) => handleUpdateQuestion(q.id, { label: html })}
placeholder="Question Text"
className="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">Choose one</SelectItem>
<SelectItem value="checkbox">Multiple choose</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 max-w-full h-auto inline-block shrink-0 group">
<Image
width={200}
height={200}
src={q.imageUrl}
alt={q.label}
unoptimized
className="w-32 h-auto rounded-md border object-contain"
/>
<Button
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
onClick={() => handleRemoveImage(q.id)}
>
<X className="w-3 h-3" />
</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" />
<Textarea
value={opt}
onChange={(e) => handleUpdateOption(q, optIdx, e.target.value)}
className="min-h-10 max-w-sm py-2"
/>
<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>
)}
{/* Preview answer field */}
<AnswerPreview question={q} />
<div className="flex justify-between items-center pt-2 border-t border-border">
<div className="flex items-center flex-wrap gap-6">
<div className="flex items-center gap-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>
{(q.type === "radio" || q.type === "checkbox") && (
<div className="flex items-center gap-2">
<Switch
id={`allowOther-${q.id}`}
checked={q.allowOther}
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { allowOther: checked })}
/>
<Label htmlFor={`allowOther-${q.id}`} className="text-sm font-normal text-muted-foreground">
Add 'Other' option
</Label>
</div>
)}
</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>
);
}
function SaveStatusIndicator({ status }: { status: AutoSaveStatus }) {
if (status === "idle") return null;
return (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "saving" && (
<>
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</>
)}
{status === "saved" && (
<>
<Check className="w-3 h-3 text-green-500" />
Saved
</>
)}
</span>
);
}
function AnswerPreview({ question }: { question: Question }) {
const q = question;
return (
<div className="rounded-md border border-dashed border-muted-foreground/30 p-3 bg-muted/30">
<Label className="text-xs text-muted-foreground mb-2 block">Answer Preview</Label>
{q.type === "text" && (
<Input
placeholder="Short answer text"
disabled
className="max-w-sm"
/>
)}
{q.type === "textarea" && (
<Textarea
placeholder="Long answer text"
disabled
className="max-w-sm min-h-20 resize-none"
/>
)}
{q.type === "radio" && (
<RadioGroup className="space-y-2">
{(q.options || []).map((opt, idx) => (
<div key={idx} className="flex items-center space-x-2">
<RadioGroupItem value={opt} id={`preview-${q.id}-${idx}`} />
<Label htmlFor={`preview-${q.id}-${idx}`} className="font-normal text-sm cursor-pointer">
<FormattedText text={opt} />
</Label>
</div>
))}
{q.allowOther && (
<div className="flex items-center space-x-2">
<RadioGroupItem value="__OTHER__" id={`preview-${q.id}-other`} />
<Label htmlFor={`preview-${q.id}-other`} className="font-normal text-sm text-muted-foreground cursor-pointer">
Other...
</Label>
</div>
)}
</RadioGroup>
)}
{q.type === "checkbox" && (
<div className="space-y-2">
{(q.options || []).map((opt, idx) => (
<div key={idx} className="flex items-center space-x-2">
<Checkbox id={`preview-${q.id}-${idx}`} />
<Label htmlFor={`preview-${q.id}-${idx}`} className="font-normal text-sm cursor-pointer">
<FormattedText text={opt} />
</Label>
</div>
))}
{q.allowOther && (
<div className="flex items-center space-x-2">
<Checkbox id={`preview-${q.id}-other`} />
<Label htmlFor={`preview-${q.id}-other`} className="font-normal text-sm text-muted-foreground cursor-pointer">
Other...
</Label>
</div>
)}
</div>
)}
</div>
);
}
export function AdminSidebar({ formId }: { formId: string }) {
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">
Form Admin
</span>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === `/admin/form/${formId}`}>
<Link href={`/admin/form/${formId}`}>
<LayoutDashboard className="w-4 h-4" />
<span>Editor</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith(`/admin/form/${formId}/result`)}>
<Link href={`/admin/form/${formId}/result`}>
<ClipboardList className="w-4 h-4" />
<span>Responses</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname.startsWith(`/admin/form/${formId}/extra`)}>
<Link href={`/admin/form/${formId}/extra`}>
<Webhook className="w-4 h-4" />
<span>Extra</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border p-4">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="/admin/form">
<ArrowLeft className="w-4 h-4" />
<span>All Forms</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
+102
View File
@@ -0,0 +1,102 @@
"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 { toast } from "sonner";
import { updateForm } from "@/app/actions/form";
import { Loader2, Save } from "lucide-react";
interface FormExtraClientProps {
form: {
id: string;
discordWebhookUrl: string | null;
discordWebhookTemplate: string | null;
discordWebhookUpdateTemplate: string | null;
};
}
export default function FormExtraClient({ form }: FormExtraClientProps) {
const router = useRouter();
const [webhookUrl, setWebhookUrl] = useState(form.discordWebhookUrl || "");
const [webhookTemplate, setWebhookTemplate] = useState(form.discordWebhookTemplate || "");
const [webhookUpdateTemplate, setWebhookUpdateTemplate] = useState(form.discordWebhookUpdateTemplate || "");
const [isSaving, startSaveTransition] = useTransition();
const handleSave = () => {
startSaveTransition(async () => {
try {
await updateForm(form.id, {
discordWebhookUrl: webhookUrl.trim() || null,
discordWebhookTemplate: webhookTemplate.trim() || null,
discordWebhookUpdateTemplate: webhookUpdateTemplate.trim() || null,
});
toast.success("Extra settings saved!");
router.refresh();
} catch (err: any) {
toast.error(err.message || "Failed to update settings.");
}
});
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Discord Webhook</CardTitle>
<CardDescription>
Send notifications to a Discord channel when a new submission is received.
Leave URL empty to disable.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Webhook URL</Label>
<Input
id="webhook-url"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-template">New Submission Template</Label>
<Textarea
id="webhook-template"
value={webhookTemplate}
onChange={(e) => setWebhookTemplate(e.target.value)}
placeholder="New submission received from {Name} for skill: {skill}!"
className="min-h-24"
/>
<p className="text-sm text-muted-foreground">
Use <code>{`{1}`}</code>, <code>{`{2}`}</code> to insert answers according to question order. Example: <code>{`{1}`}</code> for the first question.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-update-template">Update Submission Template</Label>
<Textarea
id="webhook-update-template"
value={webhookUpdateTemplate}
onChange={(e) => setWebhookUpdateTemplate(e.target.value)}
placeholder="Submission updated by {Name} for skill: {skill}!"
className="min-h-24"
/>
<p className="text-sm text-muted-foreground">
Used when a user updates their existing submission. Use <code>{`{1}`}</code>, <code>{`{2}`}</code> to insert answers.
</p>
</div>
<div className="flex justify-end pt-2">
<Button onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Settings
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { db } from "@/db";
import { notFound } from "next/navigation";
import FormExtraClient from "./client";
export const dynamic = "force-dynamic";
export default async function AdminExtraPage(
props: {
params: Promise<{ id: string }>;
}
) {
const params = await props.params;
const form = await db.query.forms.findFirst({
where: (f, { eq }) => eq(f.id, params.id),
});
if (!form) {
return notFound();
}
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">Extra Settings</h1>
<p className="text-muted-foreground mt-1">
Configure Discord webhook integrations.
</p>
</div>
</div>
<FormExtraClient form={form} />
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { redirect } from "next/navigation";
import { SidebarProvider } from "@/components/ui/sidebar";
import { AdminSidebar } from "./client";
import { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "Admin",
template: "%s | Erika!",
},
}
export default async function AdminFormLayout(
props: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}
) {
const params = await props.params;
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 w-full bg-background text-foreground dark">
<AdminSidebar formId={params.id} />
<div className="flex-1 overflow-hidden">
{props.children}
</div>
</div>
</SidebarProvider>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { db } from "@/db";
import { forms } from "@/db/schema";
import FormEditorClient from "./client";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function AdminDashboardPage(
props: {
params: Promise<{ id: string }>;
}
) {
const params = await props.params;
const form = await db.query.forms.findFirst({
where: (f, { eq }) => eq(f.id, params.id),
});
if (!form) {
return notFound();
}
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">Edit Form</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,220 @@
"use client";
import React, { useState, useEffect, useRef } 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 {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuItem,
ContextMenuGroup
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
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);
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(undefined, { hour12: false })}</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(undefined, { hour12: false })}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
{/* Answers List */}
<div className="space-y-4">
{questions.map((q) => {
const ansValue = answers[q.id] || "";
const wasEdited = editHistory.some(
(edit) => (edit.oldAnswers[q.id] ?? "") !== ansValue
);
const qAnswerHistory = editHistory.map((edit) => ({
editedAt: edit.editedAt,
answer: edit.oldAnswers[q.id] ?? "",
}));
return (
<ContextMenu key={q.id}>
<ContextMenuTrigger asChild>
<Card
className={wasEdited ? "cursor-context-menu" : ""}
>
<CardHeader className="pb-3 flex flex-row items-start justify-between space-y-0 gap-4">
<div className="space-y-1">
<div className="text-base font-semibold leading-normal">
<HtmlDisplay html={q.label} />
</div>
{wasEdited && (
<p className="text-xs text-muted-foreground italic flex items-center gap-1">
edited
</p>
)}
</div>
{q.imageUrl && (
<div className="relative w-24 h-24 overflow-hidden rounded-md border shrink-0">
<Image
src={q.imageUrl}
alt="Question image"
fill
unoptimized
className="object-cover"
/>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
{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>
)}
</CardContent>
</Card>
</ContextMenuTrigger>
{wasEdited && (
<ContextMenuContent className="w-72 max-w-sm">
<ContextMenuLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
History
</ContextMenuLabel>
<ContextMenuLabel className="text-sm font-medium text-foreground truncate pb-2">
{stripHtml(q.label)}
</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mt-1">
Previous Answers:
</ContextMenuLabel>
{qAnswerHistory.length === 0 ? (
<ContextMenuItem disabled className="text-xs text-muted-foreground italic">
No edit history for this question.
</ContextMenuItem>
) : (
<ScrollArea className="h-72">
<ContextMenuGroup>
{qAnswerHistory.map((entry, idx) => (
<ContextMenuItem
key={idx}
className="flex flex-col items-start gap-1 p-2 focus:bg-transparent cursor-default"
>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" />
<span>{new Date(entry.editedAt).toLocaleString()}</span>
</div>
<span className="text-sm text-foreground/80 whitespace-pre-wrap leading-relaxed">
{entry.answer || <span className="italic text-muted-foreground">No response</span>}
</span>
</ContextMenuItem>
))}
</ContextMenuGroup>
</ScrollArea>
)}
</ContextMenuContent>
)}
</ContextMenu>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
import { db } from "@/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { ArrowLeft, ArrowRight } from "lucide-react";
import SubmissionDetailClient from "./client";
import { DeleteSubmissionButton } from "@/components/delete-buttons";
interface SubmissionDetailPageProps {
params: Promise<{
id: string;
submissionId: string;
}>;
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id: formId, submissionId } = await params;
const submission = await db.query.submissions.findFirst({
where: (s, { eq }) => eq(s.id, submissionId),
});
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;
}
const submissionsList = await db.query.submissions.findMany({
where: (s, { eq }) => eq(s.formId, formId),
orderBy: (s, { desc }) => [desc(s.submittedAt)],
});
const currentIndex = submissionsList.findIndex((s) => s.id === submissionId);
const prevId = currentIndex > 0 ? submissionsList[currentIndex - 1].id : null;
const nextId = currentIndex >= 0 && currentIndex < submissionsList.length - 1 ? submissionsList[currentIndex + 1].id : null;
return (
<div className="space-y-6 w-full max-w-4xl mx-auto p-4 md:p-6 pb-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/admin/form/${formId}/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>
<div className="flex items-center gap-2">
{prevId && (
<Link href={`/admin/form/${formId}/result/${prevId}`}>
<Button variant="outline" size="sm">Previous</Button>
</Link>
)}
{nextId && (
<Link href={`/admin/form/${formId}/result/${nextId}`}>
<Button variant="outline" size="sm">Next</Button>
</Link>
)}
<DeleteSubmissionButton id={submission.id} redirectUrl={`/admin/form/${formId}/result`} />
</div>
</div>
<SubmissionDetailClient
submission={submission}
questions={questions}
answers={answersMap}
/>
<div className="flex justify-between items-center mt-8 pt-6 border-t">
{prevId ? (
<Link href={`/admin/form/${formId}/result/${prevId}`}>
<Button variant="outline">
<ArrowLeft className="w-4 h-4 mr-2" /> Previous
</Button>
</Link>
) : (
<div />
)}
{nextId && (
<Link href={`/admin/form/${formId}/result/${nextId}`}>
<Button variant="outline">
Next <ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
)}
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
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 { Users, ArrowLeft } from "lucide-react";
import { DeleteSubmissionButton, DeleteAllSubmissionsButton } from "@/components/delete-buttons";
export const dynamic = "force-dynamic";
export default async function ResultsListPage(
props: {
params: Promise<{ id: string }>;
}
) {
const params = await props.params;
const submissionsList = await db.query.submissions.findMany({
where: (s, { eq }) => eq(s.formId, params.id),
orderBy: (s, { desc }) => [desc(s.submittedAt)],
});
return (
<div className="space-y-6 w-full max-w-4xl mx-auto p-4 md:p-6">
<div>
<Button variant="ghost" asChild className="mb-4">
<Link href={`/admin/form/${params.id}`}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Form Editor
</Link>
</Button>
</div>
<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 formId={params.id} />}
</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 on a submission 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} className="cursor-pointer group">
<TableCell className="p-0">
<Link href={`/admin/form/${params.id}/result/${sub.id}`} className="flex items-center gap-2 p-4 w-full h-full font-medium">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{userLabel}</span>
</Link>
</TableCell>
<TableCell className="p-0 text-muted-foreground">
<Link href={`/admin/form/${params.id}/result/${sub.id}`} className="flex items-center p-4 w-full h-full">
{new Date(sub.submittedAt).toLocaleString()}
</Link>
</TableCell>
<TableCell className="text-right p-4">
<div className="flex justify-end gap-2">
<DeleteSubmissionButton id={sub.id} />
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}