diff --git a/apps/blade/package.json b/apps/blade/package.json index f4db1091..109f9728 100644 --- a/apps/blade/package.json +++ b/apps/blade/package.json @@ -17,6 +17,9 @@ "dependencies": { "@aws-sdk/client-s3": "^3.717.0", "@aws-sdk/s3-request-presigner": "^3.717.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@forge/api": "workspace:*", "@forge/auth": "workspace:*", "@forge/consts": "workspace:*", diff --git a/apps/blade/src/app/admin/forms/test-editor/page.tsx b/apps/blade/src/app/admin/forms/test-editor/page.tsx new file mode 100644 index 00000000..7ff8dfe3 --- /dev/null +++ b/apps/blade/src/app/admin/forms/test-editor/page.tsx @@ -0,0 +1,281 @@ +"use client"; + +import type { DragEndEvent } from "@dnd-kit/core"; +import type { CSSProperties } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Plus } from "lucide-react"; +import * as z from "zod"; + +import type { QuestionValidator } from "@forge/consts/knight-hacks"; +import { Button } from "@forge/ui/button"; +import { Card } from "@forge/ui/card"; +import { Input } from "@forge/ui/input"; +import { Textarea } from "@forge/ui/textarea"; + +import { QuestionEditCard } from "~/components/admin/forms/question-edit-card"; +import { api } from "~/trpc/react"; + +type FormQuestion = z.infer; +type UIQuestion = FormQuestion & { id: string }; + +// Wrapper for Sortable item +function SortableQuestion({ + question, + isActive, + onUpdate, + onDelete, + onDuplicate, + onClick, +}: { + question: UIQuestion; + isActive: boolean; + onUpdate: (q: UIQuestion) => void; + onDelete: (id: string) => void; + onDuplicate: (q: FormQuestion) => void; + onClick: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: question.id }); + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+
+ +
+
+ ); +} + +export default function FormEditorPage() { + const [formTitle, setFormTitle] = useState("Untitled Form"); + const [formDescription, setFormDescription] = useState( + "Form description goes here", + ); + const [formBanner, setFormBanner] = useState(""); + const [questions, setQuestions] = useState([ + { + id: crypto.randomUUID(), + question: "Untitled Question", + type: "SHORT_ANSWER", + optional: true, + }, + ]); + + const [saveStatus, setSaveStatus] = useState(""); + + const createFormMutation = api.forms.createForm.useMutation({ + onMutate: () => { + setSaveStatus("Saving..."); + }, + onSuccess: () => { + // eslint-disable-next-line no-console + console.log("Form saved successfully!"); + setSaveStatus(`All changes saved at ${new Date().toLocaleTimeString()}`); + }, + onError: (error) => { + // eslint-disable-next-line no-console + console.error("Failed to save form:", error); + setSaveStatus("Failed to save. Check console for details."); + }, + }); + + const handleSaveForm = () => { + // Validate banner - strictly ensure it's a URL or empty + const bannerUrl = + formBanner && z.string().url().safeParse(formBanner).success + ? formBanner + : undefined; + + createFormMutation.mutate({ + name: formTitle, + description: formDescription, + banner: bannerUrl, + questions: questions.map((q) => { + // Remove local 'id' before sending to backend + const { id: _id, ...rest } = q; + return rest; + }), + }); + }; + + const [activeQuestionId, setActiveQuestionId] = useState(null); + + // Auto-save when clicking off a card (active question changes) + const prevActiveQuestionId = useRef(null); + useEffect(() => { + if (prevActiveQuestionId.current !== activeQuestionId) { + handleSaveForm(); + } + prevActiveQuestionId.current = activeQuestionId; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeQuestionId]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setQuestions((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + + const addQuestion = () => { + const newId = crypto.randomUUID(); + const newQuestion: UIQuestion = { + id: newId, + question: "Untitled Question", + type: "SHORT_ANSWER", + optional: true, + }; + setQuestions([...questions, newQuestion]); + setActiveQuestionId(newId); + }; + + const updateQuestion = (updatedQ: UIQuestion) => { + setQuestions((prev) => + prev.map((q) => (q.id === updatedQ.id ? updatedQ : q)), + ); + }; + + const deleteQuestion = (id: string) => { + setQuestions((prev) => prev.filter((q) => q.id !== id)); + if (activeQuestionId === id) { + setActiveQuestionId(null); + } + }; + + const duplicateQuestion = (q: FormQuestion) => { + const newId = crypto.randomUUID(); + const newQ = { ...q, id: newId }; + setQuestions((prev) => [...prev, newQ]); + setActiveQuestionId(newId); + }; + + return ( +
{ + if (e.target === e.currentTarget) { + setActiveQuestionId(null); + } + }} + > +
+
+ {saveStatus} +
+ + {/*
+ +
*/} + + {/* Form Title Card */} + +
+ setFormTitle(e.target.value)} + onBlur={handleSaveForm} + /> + setFormBanner(e.target.value)} + onBlur={handleSaveForm} + /> +