refactor: migrate administrative form management to a centralized modular routing structure
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user