Files
erika/app/form/admin/client.tsx
T

419 lines
15 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";
import Image from 'next/image';
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: [],
});
router.refresh();
} catch (err: any) {
console.error("Failed to add question:", err);
}
};
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);
router.refresh();
} catch (err: any) {
console.error("Failed to delete question:", err);
}
};
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);
toast.promise(
uploadImage(formData).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 = 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 max-w-full h-auto inline-block shrink-0 group">
<Image
width={200}
height={200}
src={q.imageUrl}
alt={q.label}
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" />
<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>
);
}