import { useQueryClient } from "@tanstack/react-query"; import { useNavigate, getRouteApi } from "@tanstack/react-router"; import { useMachine } from "@xstate/react"; import type { Connection, NodeMouseHandler } from "@xyflow/react"; import { applyNodeChanges, applyEdgeChanges, ReactFlowProvider, useReactFlow } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { EdgeChange, NodeChange } from "@xyflow/react"; import { AlertCircle, BookOpenIcon, InfoIcon, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { ConceptMapCanvas } from "@/features/kit/components/concept-map-canvas"; import { SearchNodesPanel } from "@/features/kit/components/search-nodes-panel"; import { filterSelectChanges } from "@/features/kit/lib/graph-machine"; import { getLayoutedElements } from "@/features/kit/lib/layout"; import { CanvasOnboardingDialog } from "@/features/learner-map/components/canvas-onboarding-dialog"; import { LearnerToolbar } from "@/features/learner-map/components/learner-toolbar"; import { MaterialDialog } from "@/features/learner-map/components/material-dialog"; import type { Node, Edge } from "@/features/learner-map/lib/comparator"; import { useRpcMutation, useRpcQuery } from "@/hooks/use-rpc-query"; import { useStableSerializedValue } from "@/hooks/use-stable-serialized-value"; import { toast } from "@/lib/error-toast"; import { areNodesConnected, isValidConnection } from "@/lib/react-flow-types"; import { learnerMapMachine } from "@/machines/learner-map.machine"; import type { Condition } from "@/machines/learner-map.machine"; import { LearnerMapRpc } from "@/server/rpc/learner-map"; const routeApi = getRouteApi("/dashboard/learner-map/$assignmentId/"); const noop = () => {}; export function LearnerMapEditor() { const { assignmentId } = routeApi.useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { zoomIn: rfZoomIn, zoomOut: rfZoomOut, fitView } = useReactFlow(); const [snapshot, send] = useMachine(learnerMapMachine); const isSubmitted = snapshot.matches({ conceptMap: "submitted" }) || snapshot.matches({ summarizing: "submitted" }); const { data: assignmentData, isLoading } = useRpcQuery( LearnerMapRpc.getAssignmentForStudent({ assignmentId }), ); const condition: Condition = assignmentData?.studyGroup === "experiment" ? "concept_map" : assignmentData?.studyGroup === "control" ? "summarizing" : null; useEffect(() => { if (assignmentData) { send({ type: "LOADED", data: assignmentData, condition }); } }, [assignmentData, condition, send]); const saveMutation = useRpcMutation(LearnerMapRpc.saveLearnerMap(), { operation: "save learner map", }); const submitMutation = useRpcMutation(LearnerMapRpc.submitLearnerMap(), { operation: "submit learner map", }); const context = snapshot.context; const nodes = useStableSerializedValue(context.nodes); const edges = useStableSerializedValue(context.edges); const [onboardingOpen, setOnboardingOpen] = useState(false); useEffect(() => { if (assignmentData && condition === "concept_map" && !isSubmitted) { const key = `canvas-onboarding-${assignmentId}`; if (!localStorage.getItem(key)) { setOnboardingOpen(true); localStorage.setItem(key, "1"); } } }, [assignmentData, assignmentId, condition, isSubmitted]); // Debounced auto-save: saves 3s after the last change. const isConceptMapDrafting = snapshot.matches({ conceptMap: "drafting" }); useEffect(() => { if (!isConceptMapDrafting) return; const currentSnapshot = JSON.stringify({ nodes, edges }); if (currentSnapshot === context.lastSavedSnapshot) return; const timer = setTimeout(() => { saveMutation.mutate({ assignmentId, nodes: JSON.stringify(nodes), edges: JSON.stringify(edges), }); send({ type: "SAVE" }); }, 3000); return () => clearTimeout(timer); }, [ nodes, edges, assignmentId, isConceptMapDrafting, context.lastSavedSnapshot, saveMutation, send, ]); const handleSaveThenSubmit = useCallback(async () => { const saveResult = await saveMutation.mutateAsync({ assignmentId, nodes: JSON.stringify(nodes), edges: JSON.stringify(edges), }); if (!saveResult.success) return; send({ type: "SAVE" }); const submitResult = await submitMutation.mutateAsync({ assignmentId }); if (submitResult.success) { send({ type: "SUBMIT_DONE" }); void queryClient.invalidateQueries({ queryKey: LearnerMapRpc.learnerMaps() }); void navigate({ to: `/dashboard/learner-map/${assignmentId}/result` }); toast.success("Map submitted successfully"); } else { send({ type: "SUBMIT_ERROR" }); } }, [nodes, edges, assignmentId, saveMutation, submitMutation, queryClient, navigate, send]); const onNodesChange = useCallback( (changes: NodeChange[]) => { if (isSubmitted) return; const next = applyNodeChanges(filterSelectChanges(changes), nodes); send({ type: "SET_NODES", nodes: next }); }, [nodes, isSubmitted, send], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => { if (isSubmitted) return; const next = applyEdgeChanges(changes, edges); send({ type: "SET_EDGES", edges: next }); }, [edges, isSubmitted, send], ); const onConnect = useCallback( (params: Connection) => { if (isSubmitted) return; const sourceNode = nodes.find((n) => n.id === params.source); const targetNode = nodes.find((n) => n.id === params.target); if ( !isValidConnection(sourceNode?.type, targetNode?.type, params.source, params.target) ) return; if (areNodesConnected(edges, params.source, params.target)) return; const newEdge = { id: `e-${params.source}-${params.target}`, source: params.source!, target: params.target!, type: "floating" as const, style: { stroke: "#16a34a", strokeWidth: 3 }, }; send({ type: "SET_EDGES", edges: [...edges, newEdge] }); }, [nodes, edges, isSubmitted, send], ); const isValidConnectionHandler = useCallback( (params: { source: string; target: string }) => { if (isSubmitted) return false; if (params.source === params.target) return false; if (areNodesConnected(edges, params.source, params.target)) return false; const sourceNode = nodes.find((n) => n.id === params.source); const targetNode = nodes.find((n) => n.id === params.target); return isValidConnection(sourceNode?.type, targetNode?.type); }, [nodes, edges, isSubmitted], ); const handleUndo = useCallback(() => send({ type: "UNDO" }), [send]); const handleRedo = useCallback(() => send({ type: "REDO" }), [send]); const canUndo = context.pointer > 0; const canRedo = context.pointer < context.history.length - 1; const zoomIn = useCallback(() => void rfZoomIn(), [rfZoomIn]); const zoomOut = useCallback(() => void rfZoomOut(), [rfZoomOut]); const fit = useCallback(() => void fitView({ padding: 0.2 }), [fitView]); const autoLayout = useCallback(() => { if (isSubmitted) return; const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( nodes, edges, "LR", ); send({ type: "SET_NODES", nodes: layoutedNodes }); send({ type: "SET_EDGES", edges: layoutedEdges }); setTimeout(() => void fitView({ padding: 0.2 }), 50); }, [nodes, edges, isSubmitted, send, fitView]); const selectNode = useCallback( (nodeId: string) => { const node = nodes.find((n) => n.id === nodeId); if (node) { void fitView({ nodes: [node], padding: 0.5, duration: 500 }); } }, [nodes, fitView], ); const [infoOpen, setInfoOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [materialOpen, setMaterialOpen] = useState(false); const [submitDialogOpen, setSubmitDialogOpen] = useState(false); const onNodeClick: NodeMouseHandler = useCallback(() => {}, []); const onPaneClick = useCallback(() => {}, []); const handleSearchOpen = useCallback(() => setSearchOpen(true), []); const handleSearchClose = useCallback(() => setSearchOpen(false), []); const handleMaterialOpen = useCallback(() => setMaterialOpen(true), []); const handleSubmitOpen = useCallback(() => setSubmitDialogOpen(true), []); const handleControlTextChange = useCallback( (text: string) => send({ type: "SET_CONTROL_TEXT", text }), [send], ); const handleSummarizingSubmitDone = useCallback(() => { send({ type: "CONTROL_SUBMIT_DONE" }); void queryClient.invalidateQueries({ queryKey: LearnerMapRpc.learnerMaps() }); }, [send, queryClient]); const handleToggleInfo = useCallback(() => setInfoOpen((v) => !v), []); if (isLoading || snapshot.matches("loading")) { return (
Loading assignment…
); } if (snapshot.matches("error") || !assignmentData || !condition) { return (
{!condition ? "Assignment group not assigned" : "Assignment not found"}
); } if (snapshot.matches("summarizing")) { return ( ); } return (
{!infoOpen && ( )} {infoOpen && (

{assignmentData.assignment.title}

{assignmentData.assignment.description && (

{assignmentData.assignment.description}

)} {context.attempt > 0 && (

Attempt {context.attempt}

)}
{!isSubmitted && ( <>
Progress {edges.length} connections
)}
)} Submit your concept map? Your concept map will be compared against the teacher's goal map. You'll see your results immediately after submission. {context.attempt > 0 && " You can try again after viewing your results."} Cancel {submitMutation.isPending ? "Submitting..." : "Submit"}
); } interface SummarizingEditorProps { assignmentId: string; controlText: string; isSubmitted: boolean; isSubmitting: boolean; lastSavedSnapshot: string | null; assignmentTitle: string; assignmentDescription: string | null; materialText: string; onControlTextChange: (text: string) => void; onSubmitDone: () => void; } function SummarizingEditor({ assignmentId, controlText, isSubmitted, isSubmitting, lastSavedSnapshot, assignmentTitle, assignmentDescription, materialText, onControlTextChange, onSubmitDone, }: SummarizingEditorProps) { const queryClient = useQueryClient(); const saveMutation = useRpcMutation(LearnerMapRpc.saveLearnerMap(), { operation: "save summary draft", }); const submitControlTextMutation = useRpcMutation(LearnerMapRpc.submitControlText(), { operation: "submit summary", showSuccess: true, }); useEffect(() => { if (isSubmitted) return; if (controlText !== lastSavedSnapshot) { const timer = setTimeout(() => { saveMutation.mutate({ assignmentId, controlText }); }, 3000); return () => clearTimeout(timer); } }, [controlText, isSubmitted, lastSavedSnapshot, saveMutation, assignmentId]); const handleSummarySubmit = async () => { if (!controlText.trim()) { toast.error("Please enter a summary"); return; } const result = await submitControlTextMutation.mutateAsync({ assignmentId, text: controlText, }); if (result.success) { onSubmitDone(); void queryClient.invalidateQueries({ queryKey: LearnerMapRpc.learnerMaps() }); } }; return (

{assignmentTitle}

{assignmentDescription && (

{assignmentDescription}

)}

Your Summary

{materialText && ( Summary Task Write a comprehensive summary of the reading material shown on the right. Include key concepts and their relationships. )}