675 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|