/* organizer-app.jsx — Root component + auth + page routing + data fetching * React 18 · in-browser Babel · no build step * Deps: window.AdminSession, window.AdminApi, window.ORG_NAV, * window.OrgSidebar, window.OrgTopbar, window.OrgDocFooter, * window.OrgLoginScreen, window.OrgSessionsDashboard, * window.OrgOverviewPage, window.OrgMonitorPage, window.OrgPlaceholderPage, * window.OrgQuestionBankPage, window.OrgContentLibraryPage, * window.OrgSettingsPage, window.OrgProctorPage, * window.OrgCreateWizard */ // --------------------------------------------------------------------------- // OrganizerApp — Root component with auth gate + page router // --------------------------------------------------------------------------- window.OrganizerApp = function OrganizerApp() { const [authState, setAuthState] = React.useState('checking'); // 'checking' | 'login' | 'authenticated' const [admin, setAdmin] = React.useState(null); const [activePage, setActivePage] = React.useState('sessions'); const [toast, setToast] = React.useState(null); // --- Data state (fetched after auth) --- const [games, setGames] = React.useState([]); const [metrics, setMetrics] = React.useState(null); const [selectedGameId, setSelectedGameId] = React.useState(null); const [gameDetail, setGameDetail] = React.useState(null); // leaderboard, phase, players for selected game const [loading, setLoading] = React.useState(false); const [editingEvent, setEditingEvent] = React.useState(null); const [selectedEventType, setSelectedEventType] = React.useState(null); // tech-day | clinic | connect | custom | null // Resolve event_type for the currently-selected game (used to branch Monitor view) React.useEffect(function() { if (!gameDetail || !gameDetail.game || !gameDetail.game.event_id) { setSelectedEventType(null); return; } var eid = gameDetail.game.event_id; AdminApi.getEvent(eid).then(function(ev) { var cfg = ev && ev.config; if (typeof cfg === 'string') { try { cfg = JSON.parse(cfg); } catch(e) { cfg = {}; } } cfg = cfg || {}; setSelectedEventType(cfg.event_type || null); }).catch(function() { setSelectedEventType(null); }); }, [gameDetail && gameDetail.game && gameDetail.game.event_id]); // --- Toast helper --- const showToast = React.useCallback(function(msg, type) { setToast({ msg: msg, type: type || 'info' }); setTimeout(function() { setToast(null); }, 4000); }, []); // --- Auth: validate stored token on mount --- React.useEffect(function() { if (!AdminSession.isLoggedIn()) { setAuthState('login'); return; } AdminApi.me() .then(function(data) { setAdmin(data); AdminSession.setAdmin(data); setAuthState('authenticated'); }) .catch(function() { AdminSession.clear(); setAuthState('login'); }); }, []); // --- Listen for session expiry (401 from AdminApi) --- React.useEffect(function() { function onExpired() { setAuthState('login'); setAdmin(null); showToast('Session expired — please log in again', 'error'); } window.addEventListener('admin-session-expired', onExpired); return function() { window.removeEventListener('admin-session-expired', onExpired); }; }, [showToast]); // --- Login handler --- function handleLogin(email, password) { setLoading(true); return AdminApi.login(email, password) .then(function(data) { AdminSession.setToken(data.token); AdminSession.setAdmin(data.admin); setAdmin(data.admin); setAuthState('authenticated'); showToast('Welcome, ' + data.admin.display_name, 'ok'); }) .catch(function(err) { showToast(err.message || 'Login failed', 'error'); }) .finally(function() { setLoading(false); }); } // --- Logout handler --- function handleLogout() { AdminApi.logout().catch(function() {}); AdminSession.clear(); setAdmin(null); setAuthState('login'); setGames([]); setMetrics(null); setSelectedGameId(null); setGameDetail(null); } // --- Fetch games + metrics when authenticated --- React.useEffect(function() { if (authState !== 'authenticated') return; refreshGames(); refreshMetrics(); // Handle URL deep-link params try { var params = new URLSearchParams(window.location.search); var editId = params.get('edit_event_id'); var gameParam = params.get('game'); if (editId) { AdminApi.getEvent(editId) .then(function(evt) { setEditingEvent(evt); setActivePage('edit-wizard'); }) .catch(function(err) { showToast('Could not load event: ' + err.message, 'error'); }); // Clean URL so refresh doesn't re-trigger window.history.replaceState({}, '', '/v2/organizer.html'); } else if (gameParam) { // ?game=ID — jump straight to monitor for that game setSelectedGameId(gameParam); setActivePage('monitor'); window.history.replaceState({}, '', '/v2/organizer.html'); } } catch (_) {} }, [authState]); function refreshGames() { return Promise.all([ AdminApi.listGames().catch(function() { return []; }), AdminApi.listEvents().catch(function() { return []; }) ]).then(function(results) { var gameList = Array.isArray(results[0]) ? results[0] : (results[0].games || []); var eventList = Array.isArray(results[1]) ? results[1] : (results[1].events || []); // Find events that don't yet have a game/session — surface as draft rows var gameEventIds = {}; gameList.forEach(function(g) { if (g.event_id) gameEventIds[g.event_id] = true; }); var draftEvents = eventList.filter(function(e) { return !gameEventIds[e.event_id]; }); // Normalize draft events into the row shape expected by the dashboard var draftRows = draftEvents.map(function(e) { var cfg = {}; try { cfg = typeof e.config === 'string' ? JSON.parse(e.config) : (e.config || {}); } catch(_) { cfg = {}; } return { game_id: e.event_id, event_id: e.event_id, join_code: '— draft —', name: e.name, location: e.location, created_at: e.created_at, current_phase: 'draft', is_event_draft: true, event_type: cfg.event_type || e.event_type || '' }; }); setGames(draftRows.concat(gameList)); }) .catch(function(err) { console.error('[organizer] refreshGames failed:', err); }); } function refreshMetrics() { AdminApi.metricsOverview() .then(function(data) { setMetrics(data); }) .catch(function(err) { console.error('[organizer] metrics failed:', err); }); } // --- Fetch game detail (component-level so it can be passed as prop) --- function fetchDetail() { if (!selectedGameId) return; Promise.all([ AdminApi.getGame(selectedGameId), AdminApi.getLeaderboard(selectedGameId).catch(function() { return []; }), AdminApi.getAllPlayers(selectedGameId).catch(function() { return []; }), AdminApi.getGamePhase(selectedGameId).catch(function() { return { phase: 'unknown' }; }), AdminApi.getTimerStatus(selectedGameId).catch(function() { return {}; }), ]).then(function(results) { setGameDetail({ game: results[0], leaderboard: Array.isArray(results[1]) ? results[1] : results[1].leaderboard || [], players: Array.isArray(results[2]) ? results[2] : results[2].players || [], phase: results[3].phase || results[3], timer: results[4], }); }).catch(function(err) { console.error('[organizer] game detail fetch failed:', err); }); } // --- Auto-poll game detail when a game is selected --- React.useEffect(function() { if (!selectedGameId) { setGameDetail(null); return; } fetchDetail(); // Poll every 5s when on monitor page var interval = setInterval(fetchDetail, 5000); return function() { clearInterval(interval); }; }, [selectedGameId]); // --- Create game handler --- function handleCreateGame(name, location) { var createdBy = admin ? admin.email : ''; setLoading(true); return AdminApi.createGame(name, location, createdBy) .then(function(data) { showToast('Game created: ' + data.game_id, 'ok'); refreshGames(); return data; }) .catch(function(err) { showToast('Create failed: ' + err.message, 'error'); throw err; }) .finally(function() { setLoading(false); }); } // --- Delete game handler --- function handleDeleteGame(gameId) { var row = games.find(function(g) { return g.game_id === gameId; }); var isDraft = row && row.is_event_draft; var label = isDraft ? 'event ' + row.event_id : 'game ' + gameId; if (!confirm('Delete ' + label + '? This cannot be undone.')) return; var apiCall = isDraft ? AdminApi.deleteEvent(row.event_id) : AdminApi.deleteGame(gameId); apiCall .then(function() { showToast(isDraft ? 'Event deleted' : 'Game deleted', 'ok'); if (selectedGameId === gameId) setSelectedGameId(null); refreshGames(); }) .catch(function(err) { showToast('Delete failed: ' + err.message, 'error'); }); } // --- Phase control handlers --- function handleSetPhase(phase) { if (!selectedGameId) { showToast('Select a game first', 'error'); return; } AdminApi.setGamePhase(selectedGameId, phase) .then(function() { showToast('Phase → ' + phase, 'ok'); // Optimistic update so UI reacts immediately (no 5s wait) setGameDetail(function(prev) { if (!prev) return prev; return Object.assign({}, prev, { phase: phase }); }); }) .catch(function(err) { showToast('Phase change failed: ' + err.message, 'error'); }); } function handleTimerAction(action, phaseId, value) { if (!selectedGameId) return; var gameId = selectedGameId; var pid = phaseId || 'quiz'; var p; if (action === 'start') { // Ensure game phase and sub_phase are aligned with the timer's phase, then start. // Uses local gameDetail.phase to avoid an extra round-trip (idempotent if already correct). var currentPhase = gameDetail && gameDetail.phase; var phaseFix = (currentPhase !== 'playing') ? AdminApi.setGamePhase(gameId, 'playing') : Promise.resolve(); p = phaseFix .then(function() { return AdminApi.setSubPhase(gameId, pid); }) .then(function() { return AdminApi.startTimer(gameId, pid); }); p.then(function() { showToast('Phase advanced + timer started', 'ok'); }) .catch(function(err) { showToast('Timer start failed: ' + err.message, 'error'); }); return; } else if (action === 'pause') { p = AdminApi.pauseTimer(gameId, pid); } else if (action === 'restart') { p = AdminApi.restartTimer(gameId); } else if (action === 'set-duration') { p = AdminApi.setTimerDuration(gameId, pid, value); } else if (action === 'extend') { p = AdminApi.extendTimer(gameId, pid, value); } else return; p.then(function() { showToast('Timer ' + action, 'ok'); }) .catch(function(err) { showToast('Timer failed: ' + err.message, 'error'); }); } function handleResetGame() { if (!selectedGameId) return; AdminApi.resetGame(selectedGameId) .then(function() { showToast('Game reset', 'ok'); }) .catch(function(err) { showToast('Reset failed: ' + err.message, 'error'); }); } // --- Breadcrumbs --- var breadcrumbs = React.useMemo(function() { switch (activePage) { case 'sessions': return ['Workspace', 'Sessions']; case 'overview': return ['Workspace', 'Overview']; case 'question-bank': return ['Workspace', 'Content library']; case 'content-library': return ['Workspace', 'Content library']; case 'monitor': return ['Operate', 'Live monitor', selectedGameId || '']; case 'proctor': return ['Operate', 'Proctor queue']; case 'results': return ['Operate', 'Results & reports']; case 'settings': return ['System', 'Settings']; case 'create-wizard': return ['Workspace', 'New session']; default: return ['Workspace', 'Sessions']; } }, [activePage, selectedGameId]); // --- Navigate to monitor for a specific game --- function openMonitor(gameId) { setSelectedGameId(gameId); setActivePage('monitor'); } // --- Render --- // Checking auth if (authState === 'checking') { return (

Verifying session…

); } // Login screen if (authState === 'login') { return ( <> {toast &&
{toast.msg}
} ); } // Authenticated — render app shell var pageContent; switch (activePage) { case 'sessions': pageContent = ( ); break; case 'overview': pageContent = ( ); break; case 'monitor': if (selectedGameId && gameDetail && selectedEventType === 'tech-day' && window.OrgMonitorTechDay) { pageContent = ( ); } else { pageContent = ( ); } break; case 'question-bank': case 'content-library': pageContent = ( ); break; case 'results': pageContent = ( ); break; case 'settings': pageContent = ( ); break; case 'proctor': pageContent = ( ); break; case 'create-wizard': pageContent = ( ); break; case 'edit-wizard': pageContent = ( ); break; default: pageContent = ( ); } return (
{pageContent}
{toast &&
{toast.msg}
}
); }; // --------------------------------------------------------------------------- // TweakPanel — Ctrl+Shift+T toggled panel for CSS custom property overrides // --------------------------------------------------------------------------- (function () { var HUES = [ { label: 'Cisco', value: 230 }, { label: 'F1', value: 12 }, { label: 'Match', value: 152 }, { label: 'TV', value: 300 }, ]; window.OrgTweakPanel = function OrgTweakPanel() { var ref = React.useState(false), visible = ref[0], setVisible = ref[1]; var ref2 = React.useState(230), activeHue = ref2[0], setActiveHue = ref2[1]; React.useEffect(function() { function onKey(e) { if (e.ctrlKey && e.shiftKey && e.key === 'T') setVisible(function(v) { return !v; }); } window.addEventListener('keydown', onKey); return function() { window.removeEventListener('keydown', onKey); }; }, []); function applyHue(hue) { setActiveHue(hue); var root = document.documentElement; root.style.setProperty('--brand', 'oklch(62% 0.18 ' + hue + ')'); root.style.setProperty('--brand-soft', 'oklch(28% 0.08 ' + hue + ')'); } if (!visible) return null; return (
Brand hue
{HUES.map(function(h) { return ; })}
); }; })(); // --------------------------------------------------------------------------- // Mount // --------------------------------------------------------------------------- (function () { var container = document.getElementById('app'); if (!container) { console.error('[organizer-app] No #app element found.'); return; } var root = ReactDOM.createRoot(container); root.render( <> ); })();