401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { toast } from "sonner";
|
|
import { updateForm } from "@/app/actions/form";
|
|
import {
|
|
createQuestion,
|
|
updateQuestion,
|
|
deleteQuestion,
|
|
reorderQuestions,
|
|
} from "@/app/actions/questions";
|
|
import { uploadImage } from "@/app/actions/upload";
|
|
import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react";
|
|
|
|
interface Question {
|
|
id: string;
|
|
formId: string;
|
|
type: "text" | "textarea" | "radio" | "checkbox";
|
|
label: string;
|
|
imageUrl: string | null;
|
|
displayOrder: number;
|
|
required: boolean;
|
|
options: string[] | null;
|
|
}
|
|
|
|
interface FormEditorClientProps {
|
|
form: {
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
isOpen: boolean;
|
|
};
|
|
initialQuestions: Question[];
|
|
}
|
|
|
|
export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) {
|
|
const router = useRouter();
|
|
const [formTitle, setFormTitle] = useState(form.title);
|
|
const [formDescription, setFormDescription] = useState(form.description || "");
|
|
const [formIsOpen, setFormIsOpen] = useState(form.isOpen);
|
|
const [questions, setQuestions] = useState<Question[]>(initialQuestions);
|
|
const [uploadingId, setUploadingId] = useState<string | null>(null);
|
|
const [isFormSaving, startFormSaveTransition] = useTransition();
|
|
|
|
const saveFormMetadata = () => {
|
|
if (!formTitle.trim()) {
|
|
toast.error("Form title is required.");
|
|
return;
|
|
}
|
|
|
|
startFormSaveTransition(async () => {
|
|
try {
|
|
await updateForm(form.id, {
|
|
title: formTitle,
|
|
description: formDescription,
|
|
isOpen: formIsOpen,
|
|
});
|
|
toast.success("Form metadata saved!");
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to update form.");
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleAddQuestion = async () => {
|
|
try {
|
|
const displayOrder = questions.length;
|
|
await createQuestion(form.id, {
|
|
type: "text",
|
|
label: `Untitled Question ${questions.length + 1}`,
|
|
required: false,
|
|
displayOrder,
|
|
options: [],
|
|
});
|
|
toast.success("Question added!");
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to add question.");
|
|
}
|
|
};
|
|
|
|
const handleUpdateQuestion = async (id: string, updates: Partial<Question>) => {
|
|
try {
|
|
setQuestions((prev) =>
|
|
prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q))
|
|
);
|
|
|
|
const formattedUpdates = {
|
|
...updates,
|
|
options: updates.options === null ? [] : updates.options,
|
|
};
|
|
|
|
await updateQuestion(id, form.id, formattedUpdates);
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to update question.");
|
|
}
|
|
};
|
|
|
|
const handleDeleteQuestion = async (id: string) => {
|
|
try {
|
|
await deleteQuestion(id, form.id);
|
|
toast.success("Question deleted!");
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to delete question.");
|
|
}
|
|
};
|
|
|
|
const handleMove = async (index: number, direction: "up" | "down") => {
|
|
const newQuestions = [...questions];
|
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (targetIndex < 0 || targetIndex >= newQuestions.length) return;
|
|
|
|
const temp = newQuestions[index];
|
|
newQuestions[index] = newQuestions[targetIndex];
|
|
newQuestions[targetIndex] = temp;
|
|
|
|
setQuestions(newQuestions);
|
|
|
|
try {
|
|
await reorderQuestions(form.id, newQuestions.map((q) => q.id));
|
|
toast.success("Order updated!");
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to reorder questions.");
|
|
}
|
|
};
|
|
|
|
const handleImageUpload = async (questionId: string, e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
setUploadingId(questionId);
|
|
try {
|
|
const imageUrl = await uploadImage(formData);
|
|
await handleUpdateQuestion(questionId, { imageUrl });
|
|
toast.success("Image uploaded!");
|
|
router.refresh();
|
|
} catch (err: any) {
|
|
toast.error(err.message || "Failed to upload image.");
|
|
} finally {
|
|
setUploadingId(null);
|
|
}
|
|
};
|
|
|
|
const handleRemoveImage = async (questionId: string) => {
|
|
await handleUpdateQuestion(questionId, { imageUrl: null });
|
|
toast.success("Image removed.");
|
|
router.refresh();
|
|
};
|
|
|
|
const handleAddOption = async (question: Question) => {
|
|
const options = question.options ? [...question.options] : [];
|
|
options.push(`Option ${options.length + 1}`);
|
|
await handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
const handleUpdateOption = async (question: Question, optIndex: number, val: string) => {
|
|
if (!question.options) return;
|
|
const options = [...question.options];
|
|
options[optIndex] = val;
|
|
await handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
const handleRemoveOption = async (question: Question, optIndex: number) => {
|
|
if (!question.options) return;
|
|
const options = question.options.filter((_, idx) => idx !== optIndex);
|
|
await handleUpdateQuestion(question.id, { options });
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
setQuestions(initialQuestions);
|
|
}, [initialQuestions]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Form Settings</CardTitle>
|
|
<CardDescription>Configure the main title and description of your form.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="form-title">Form Title</Label>
|
|
<Input
|
|
id="form-title"
|
|
value={formTitle}
|
|
onChange={(e) => setFormTitle(e.target.value)}
|
|
placeholder="e.g. Untitled Form"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="form-description">Description</Label>
|
|
<Textarea
|
|
id="form-description"
|
|
value={formDescription}
|
|
onChange={(e) => setFormDescription(e.target.value)}
|
|
placeholder="Provide a description..."
|
|
className="min-h-20"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center space-x-2 pt-2">
|
|
<Switch
|
|
id="form-is-open"
|
|
checked={formIsOpen}
|
|
onCheckedChange={setFormIsOpen}
|
|
/>
|
|
<Label htmlFor="form-is-open" className="text-sm font-normal text-muted-foreground">
|
|
Accepting Submissions
|
|
</Label>
|
|
</div>
|
|
<div className="flex justify-end pt-2">
|
|
<Button onClick={saveFormMetadata} disabled={isFormSaving} className="gap-2">
|
|
{isFormSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
Save Settings
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Separator />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-bold">Questions</h2>
|
|
<Button onClick={handleAddQuestion} className="gap-2">
|
|
<Plus className="w-4 h-4" />
|
|
Add Question
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{questions.map((q, index) => (
|
|
<Card key={q.id} className="relative">
|
|
<CardContent className="pt-6 space-y-4">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
<div className="flex-1 w-full">
|
|
<Input
|
|
value={q.label}
|
|
onChange={(e) => handleUpdateQuestion(q.id, { label: e.target.value })}
|
|
placeholder="Question Text"
|
|
className="font-medium text-base w-full"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 self-end sm:self-auto">
|
|
<Select
|
|
value={q.type}
|
|
onValueChange={(val: any) => handleUpdateQuestion(q.id, { type: val })}
|
|
>
|
|
<SelectTrigger className="w-36">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">Short Answer</SelectItem>
|
|
<SelectItem value="textarea">Paragraph</SelectItem>
|
|
<SelectItem value="radio">Multiple Choice</SelectItem>
|
|
<SelectItem value="checkbox">Checkboxes</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="flex items-center gap-1 border rounded-md p-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === 0}
|
|
onClick={() => handleMove(index, "up")}
|
|
>
|
|
<ArrowUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
disabled={index === questions.length - 1}
|
|
onClick={() => handleMove(index, "down")}
|
|
>
|
|
<ArrowDown className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{q.imageUrl ? (
|
|
<div className="relative inline-block border rounded-md p-1 bg-muted">
|
|
<img
|
|
src={q.imageUrl}
|
|
alt={q.label}
|
|
className="max-h-40 rounded object-contain"
|
|
/>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
className="absolute -top-2 -right-2 h-6 w-6 rounded-full"
|
|
onClick={() => handleRemoveImage(q.id)}
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor={`img-upload-${q.id}`}
|
|
className="flex items-center gap-2 px-3 py-2 border rounded-md cursor-pointer hover:bg-accent text-sm text-muted-foreground"
|
|
>
|
|
{uploadingId === q.id ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
|
) : (
|
|
<ImageIcon className="w-4 h-4 text-primary" />
|
|
)}
|
|
<span>Add Image</span>
|
|
</Label>
|
|
<input
|
|
id={`img-upload-${q.id}`}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => handleImageUpload(q.id, e)}
|
|
className="hidden"
|
|
disabled={uploadingId !== null}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(q.type === "radio" || q.type === "checkbox") && (
|
|
<div className="space-y-2 pl-4 border-l-2 border-primary/20">
|
|
<Label className="text-sm font-semibold">Options</Label>
|
|
<div className="space-y-2">
|
|
{(q.options || []).map((opt, optIdx) => (
|
|
<div key={optIdx} className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-muted-foreground" />
|
|
<Input
|
|
value={opt}
|
|
onChange={(e) => handleUpdateOption(q, optIdx, e.target.value)}
|
|
className="h-8 max-w-sm"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => handleRemoveOption(q, optIdx)}
|
|
>
|
|
<Trash className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleAddOption(q)}
|
|
className="text-primary hover:text-primary/80 h-8 gap-1 pl-1"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add Option
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center pt-2 border-t border-border">
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id={`required-${q.id}`}
|
|
checked={q.required}
|
|
onCheckedChange={(checked) => handleUpdateQuestion(q.id, { required: checked })}
|
|
/>
|
|
<Label htmlFor={`required-${q.id}`} className="text-sm font-normal text-muted-foreground">
|
|
Required
|
|
</Label>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleDeleteQuestion(q.id)}
|
|
className="text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|