import { useState, useEffect, useCallback, useRef } from 'react' import Sidebar from './components/Sidebar' import ScenarioPanel from './components/ScenarioPanel' import Terminal from './components/Terminal' import Header from './components/Header' import BundleNav from './components/BundleNav' import ExamTimer from './components/ExamTimer' import ExamReport from './components/ExamReport' import ExamStartModal from './components/ExamStartModal' import styles from './App.module.css' const MIN_SIDEBAR_W = 180 const MAX_SIDEBAR_W = 560 const DEFAULT_SIDEBAR_W = 280 const SIDEBAR_COLLAPSE_PX = 100 const SIDEBAR_COLLAPSED_W = 40 const MIN_TERM_H = 36 const MAX_TERM_H = 600 const DEFAULT_TERM_H = 280 const TERM_COLLAPSE_PX = 60 export default function App() { const [bundles, setBundles] = useState([]) const [activeBundleId, setActiveBundleId] = useState(null) const [scenarios, setScenarios] = useState([]) const [activeId, setActiveId] = useState(null) const [scenario, setScenario] = useState(null) const [progress, setProgress] = useState({}) const [clusterReady, setClusterReady] = useState(false) const [loading, setLoading] = useState(true) // ── Exam mode ───────────────────────────────────────────────────────────── const [examSession, setExamSession] = useState(null) // active session object const [examReport, setExamReport] = useState(null) // submitted report const [examModalBundle, setExamModalBundle] = useState(null) // bundle for start/retry modal // Sidebar resize / collapse const [sidebarW, setSidebarW] = useState(DEFAULT_SIDEBAR_W) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const sbDragging = useRef(false) const sbDragX0 = useRef(0) const sbDragW0 = useRef(0) // Terminal resize / collapse const [termH, setTermH] = useState(300) const [termCollapsed, setTermCollapsed] = useState(false) const [bundlesCollapsed, setBundlesCollapsed] = useState(false) const tmDragging = useRef(false) const tmDragY0 = useRef(0) const tmDragH0 = useRef(0) // Track previous scenario id to teardown on switch const prevActiveIdRef = useRef(null) // ── Cluster health ──────────────────────────────────────────────────────── useEffect(() => { async function check() { try { const d = await fetch('/api/health').then(r => r.json()) setClusterReady(d.cluster === 'ready') } catch { setClusterReady(false) } } check() const t = setInterval(check, 8000) return () => clearInterval(t) }, []) // ── Load bundles (once) ─────────────────────────────────────────────────── useEffect(() => { fetch('/api/bundles') .then(r => r.json()) .then(data => { setBundles(data) if (data.length > 0) setActiveBundleId(data[0].id) }) .catch(console.error) }, []) // ── Restore active exam session on load ─────────────────────────────────── useEffect(() => { fetch('/api/sessions/active') .then(r => r.json()) .then(s => { if (s) setExamSession(s) }) .catch(() => { }) }, []) // ── Load scenarios for active bundle ───────────────────────────────────── useEffect(() => { if (!activeBundleId) return setLoading(true) setActiveId(null) setScenario(null) const url = `/api/scenarios?bundle=${activeBundleId}` fetch(url) .then(r => r.json()) .then(data => { setScenarios(data) setProgress(Object.fromEntries(data.map(s => [s.id, s.progress]))) }) .catch(console.error) .finally(() => setLoading(false)) }, [activeBundleId]) // ── Load full scenario when selected — teardown previous, load new ───────── useEffect(() => { if (!activeId) return // Teardown the previously active scenario's cluster state on switch const prevId = prevActiveIdRef.current if (prevId && prevId !== activeId) { fetch(`/api/scenarios/${prevId}/teardown`, { method: 'POST' }).catch(() => { }) } prevActiveIdRef.current = activeId setScenario(null) fetch(`/api/scenarios/${activeId}`) .then(r => r.json()) .then(s => { setScenario(s) // Feature 3: sync terminal context when scenario selected fetch(`/api/scenarios/${activeId}/context`, { method: 'POST' }).catch(() => { }) }) .catch(console.error) }, [activeId]) const refreshProgress = useCallback(async () => { const [bundleData, scenarioData] = await Promise.all([ fetch('/api/bundles').then(r => r.json()), fetch(`/api/scenarios?bundle=${activeBundleId}`).then(r => r.json()), ]) setBundles(bundleData) setScenarios(scenarioData) setProgress(Object.fromEntries(scenarioData.map(s => [s.id, s.progress]))) if (activeId) { const d2 = await fetch(`/api/scenarios/${activeId}`).then(r => r.json()) setScenario(d2) } // Refresh exam session completion count if (examSession) { const updated = await fetch('/api/sessions/active').then(r => r.json()).catch(() => null) if (updated) setExamSession(updated) } }, [activeBundleId, activeId, examSession]) // ── Exam actions ────────────────────────────────────────────────────────── const startExam = useCallback(async (bundleId, customMinutes) => { const res = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bundleId, examMinutes: customMinutes }), }).then(r => r.json()) // refresh to get scenarioCount const active = await fetch('/api/sessions/active').then(r => r.json()) setExamSession(active) setActiveBundleId(bundleId) setActiveId(null) setScenario(null) }, []) const submitExam = useCallback(async () => { if (!examSession) return const result = await fetch(`/api/sessions/${examSession.id}/submit`, { method: 'POST' }) .then(r => r.json()) const bundle = bundles.find(b => b.id === examSession.bundle_id) setExamReport({ ...result, bundle }) setExamSession(null) }, [examSession, bundles]) const abandonExam = useCallback(async () => { if (!examSession) return await fetch(`/api/sessions/${examSession.id}/abandon`, { method: 'POST' }).catch(() => { }) setExamSession(null) }, [examSession]) // ── Teardown when restarting a scenario (Feature 2) ─────────────────────── const handleScenarioStart = useCallback(async (scenarioId) => { // Run teardown first to clean cluster state await fetch(`/api/scenarios/${scenarioId}/teardown`, { method: 'POST' }).catch(() => { }) // Then setup will be called by ScenarioPanel as before }, []) // ── Sidebar drag ───────────────────────────────────────────────────────── const onSidebarDragDown = useCallback((e) => { e.preventDefault() sbDragging.current = true sbDragX0.current = e.clientX sbDragW0.current = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW function onMove(ev) { if (!sbDragging.current) return const newW = sbDragW0.current + (ev.clientX - sbDragX0.current) if (newW < SIDEBAR_COLLAPSE_PX) { setSidebarCollapsed(true) } else { setSidebarCollapsed(false) setSidebarW(Math.min(MAX_SIDEBAR_W, Math.max(MIN_SIDEBAR_W, newW))) } } function onUp() { sbDragging.current = false window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }, [sidebarCollapsed, sidebarW]) // ── Terminal drag ──────────────────────────────────────────────────────── const onTermDragDown = useCallback((e) => { e.preventDefault() tmDragging.current = true tmDragY0.current = e.clientY tmDragH0.current = termCollapsed ? MIN_TERM_H : termH function onMove(ev) { if (!tmDragging.current) return const newH = tmDragH0.current + (tmDragY0.current - ev.clientY) if (newH < TERM_COLLAPSE_PX) { setTermCollapsed(true) } else { setTermCollapsed(false) setTermH(Math.min(MAX_TERM_H, Math.max(MIN_TERM_H + 40, newH))) } } function onUp() { tmDragging.current = false window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }, [termCollapsed, termH]) const currentSidebarW = sidebarCollapsed ? SIDEBAR_COLLAPSED_W : sidebarW const currentTermH = termCollapsed ? MIN_TERM_H : termH const activeBundle = bundles.find(b => b.id === activeBundleId) || null const isMcq = scenario?.type === 'mcq' return (