Files
erika/app/admin/form/[id]/client.tsx
T

675 lines
24 KiB
TypeScript

"use client";
import React, { useState, 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/admin/form/actions";
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, 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";
import { ThemeToggle } from "@/components/theme-toggle";
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>
<RichTextEditor
value={formDescription}
onChange={(html) => handleFormDescriptionChange(html)}
placeholder="Provide a description..."
className="min-h-20 w-full"
/>
</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 flex-row items-center justify-between">
<span className="font-bold tracking-wider text-primary text-sm uppercase">
Form Admin
</span>
<ThemeToggle />
</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>
);
}