import * as Blockly from 'blockly'; import './blocks/esp32_blocks.js'; import './blocks/esp32_generators.js'; import './blocks/arduino_generators.js'; import { buildToolbox, getDeviceId } from './devices/registry.js'; import { loadAllSavedAddons } from './addons/loader.js'; import { initLoginDialog } from './ui/loginDialog.js'; import { getUser, getToken, refreshMe, onAuthChange, logout, } from './auth/client.js'; import { connectTeacher, disconnectTeacher, sendTeacher } from './ws/teacherClient.js'; import './style.css'; import './teacher.css'; loadAllSavedAddons(); const MAIN_WORKSPACE_KEY = 'esp32block_workspace'; const statusEl = document.getElementById('teacher-status'); const countEl = document.getElementById('teacher-count'); const gridEl = document.getElementById('teacher-grid'); const emptyEl = document.getElementById('teacher-empty'); const badgeEl = document.getElementById('user-badge'); const logoutBtn = document.getElementById('teacher-logout'); const overlayEl = document.getElementById('student-overlay'); const modalTitle = document.getElementById('student-modal-title'); const modalPreview = document.getElementById('student-modal-preview'); const modalClose = document.getElementById('student-modal-close'); const modalPushBtn = document.getElementById('student-push-btn'); const modalStatus = document.getElementById('student-modal-status'); const hiddenWsDiv = document.getElementById('teacher-hidden-ws'); // Map userId -> { username, state, tileEl, blockly, emptyLabel } const students = new Map(); let modalBlockly = null; let focusedUserId = null; // Hidden workspace that mirrors the teacher's own IDE state from localStorage. // This is the "my current blocks" that gets pushed to students. let hiddenWorkspace = null; let modalResizeHandler = null; function ensureHiddenWorkspace() { if (hiddenWorkspace) return hiddenWorkspace; hiddenWsDiv.style.width = '800px'; hiddenWsDiv.style.height = '600px'; hiddenWorkspace = Blockly.inject(hiddenWsDiv, { toolbox: buildToolbox(getDeviceId()), readOnly: true, theme: Blockly.Themes.Dark, renderer: 'zelos', }); loadFromStorageIntoHidden(); window.addEventListener('storage', (e) => { if (e.key === MAIN_WORKSPACE_KEY) loadFromStorageIntoHidden(); }); return hiddenWorkspace; } function loadFromStorageIntoHidden() { if (!hiddenWorkspace) return; const raw = localStorage.getItem(MAIN_WORKSPACE_KEY); if (!raw) return; try { const state = JSON.parse(raw); Blockly.serialization.workspaces.load(state, hiddenWorkspace); } catch { /* ignore */ } } function getTeacherCurrentState() { ensureHiddenWorkspace(); loadFromStorageIntoHidden(); try { return Blockly.serialization.workspaces.save(hiddenWorkspace); } catch { return null; } } function setStatus(text, cls) { if (!statusEl) return; statusEl.textContent = text; statusEl.className = cls || ''; } function updateCount() { const n = students.size; countEl.textContent = `${n} student${n === 1 ? '' : 's'} online`; emptyEl.classList.toggle('hidden', n > 0); } function createTile(userId, username) { const tile = document.createElement('div'); tile.className = 'student-tile'; tile.dataset.userId = String(userId); const header = document.createElement('div'); header.className = 'student-tile-header'; const nameSpan = document.createElement('span'); nameSpan.className = 'student-tile-name'; nameSpan.textContent = username; const statusSpan = document.createElement('span'); statusSpan.className = 'student-tile-status'; statusSpan.textContent = '● live'; header.appendChild(nameSpan); header.appendChild(statusSpan); const blocklyHost = document.createElement('div'); blocklyHost.className = 'student-tile-blockly'; const emptyLabel = document.createElement('div'); emptyLabel.className = 'student-tile-empty'; emptyLabel.textContent = 'No blocks yet'; blocklyHost.appendChild(emptyLabel); tile.appendChild(header); tile.appendChild(blocklyHost); tile.addEventListener('click', () => openStudentModal(userId)); gridEl.appendChild(tile); const blockly = Blockly.inject(blocklyHost, { readOnly: true, theme: Blockly.Themes.Dark, renderer: 'zelos', zoom: { controls: false, wheel: false, startScale: 0.45 }, trashcan: false, scrollbars: false, move: { scrollbars: false, drag: false, wheel: false }, }); return { tile, blockly, blocklyHost, emptyLabel, nameSpan }; } function applyStateToWorkspace(state, blocklyWs) { try { if (state && state.blocks && Array.isArray(state.blocks.blocks) && state.blocks.blocks.length > 0) { Blockly.serialization.workspaces.load(state, blocklyWs); return true; } blocklyWs.clear(); return false; } catch { blocklyWs.clear(); return false; } } function upsertStudent({ userId, username, state }) { let entry = students.get(userId); if (!entry) { const parts = createTile(userId, username); entry = { userId, username, state: null, ...parts, }; students.set(userId, entry); updateCount(); } else if (entry.username !== username) { entry.username = username; entry.nameSpan.textContent = username; } entry.state = state || null; const hasBlocks = applyStateToWorkspace(entry.state, entry.blockly); entry.emptyLabel.style.display = hasBlocks ? 'none' : ''; Blockly.svgResize(entry.blockly); if (focusedUserId === userId) { refreshModalFromEntry(entry); } } function removeStudent(userId) { const entry = students.get(userId); if (!entry) return; try { entry.blockly.dispose(); } catch { /* ignore */ } entry.tile.remove(); students.delete(userId); updateCount(); if (focusedUserId === userId) closeStudentModal(); } function clearAllStudents() { for (const id of Array.from(students.keys())) removeStudent(id); } function refreshModalFromEntry(entry) { if (!modalBlockly) return; modalTitle.textContent = entry.username; applyStateToWorkspace(entry.state, modalBlockly); Blockly.svgResize(modalBlockly); } function openStudentModal(userId) { const entry = students.get(userId); if (!entry) return; focusedUserId = userId; overlayEl.classList.remove('hidden'); modalStatus.textContent = ''; modalStatus.className = 'login-status'; if (!modalBlockly) { modalBlockly = Blockly.inject(modalPreview, { readOnly: true, theme: Blockly.Themes.Dark, renderer: 'zelos', zoom: { controls: true, wheel: true, startScale: 0.85 }, trashcan: false, }); modalResizeHandler = () => { if (modalBlockly) Blockly.svgResize(modalBlockly); }; window.addEventListener('resize', modalResizeHandler); } requestAnimationFrame(() => { refreshModalFromEntry(entry); }); } function closeStudentModal() { focusedUserId = null; overlayEl.classList.add('hidden'); } modalClose.addEventListener('click', closeStudentModal); overlayEl.addEventListener('click', (e) => { if (e.target === overlayEl) closeStudentModal(); }); modalPushBtn.addEventListener('click', () => { if (focusedUserId == null) return; const state = getTeacherCurrentState(); if (!state) { modalStatus.textContent = 'No local blocks found to push. Open the main IDE first.'; modalStatus.className = 'login-status status-err'; return; } modalPushBtn.disabled = true; modalStatus.textContent = 'Pushing…'; modalStatus.className = 'login-status'; const ok = sendTeacher({ type: 'push-code', targetUserId: focusedUserId, state }); if (!ok) { modalStatus.textContent = 'Not connected.'; modalStatus.className = 'login-status status-err'; modalPushBtn.disabled = false; } }); function handleMessage(msg) { if (msg.type === 'roster') { const incoming = new Set(); for (const s of msg.students || []) { incoming.add(s.userId); upsertStudent(s); } for (const id of Array.from(students.keys())) { if (!incoming.has(id)) removeStudent(id); } return; } if (msg.type === 'student-update') { upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state }); return; } if (msg.type === 'student-leave') { removeStudent(msg.userId); return; } if (msg.type === 'push-code-result') { modalPushBtn.disabled = false; if (msg.ok) { modalStatus.textContent = 'Pushed!'; modalStatus.className = 'login-status status-ok'; } else { modalStatus.textContent = msg.error || 'Push failed'; modalStatus.className = 'login-status status-err'; } return; } } function renderBadge(user) { if (!badgeEl) return; if (user) { badgeEl.textContent = `${user.username}${user.role === 'teacher' ? ' (teacher)' : ''}`; badgeEl.classList.remove('hidden'); } else { badgeEl.textContent = ''; badgeEl.classList.add('hidden'); } } function connectIfTeacher() { const user = getUser(); const token = getToken(); renderBadge(user); clearAllStudents(); if (!user || !token) { setStatus('Not signed in', 'status-disconnected'); disconnectTeacher(); openLoginModal(); return; } if (user.role !== 'teacher') { setStatus('Teacher account required', 'status-disconnected'); disconnectTeacher(); return; } setStatus('Connecting…', 'status-disconnected'); connectTeacher({ token, onOpen: () => { setStatus('Connected', 'status-connected'); sendTeacher({ type: 'list' }); }, onClose: () => setStatus('Disconnected', 'status-disconnected'), onMessage: handleMessage, }); } function openLoginModal() { const overlay = document.getElementById('login-overlay'); if (overlay) overlay.classList.remove('hidden'); } logoutBtn?.addEventListener('click', async () => { await logout(); }); initLoginDialog(); onAuthChange(() => connectIfTeacher()); ensureHiddenWorkspace(); updateCount(); refreshMe() .then(() => connectIfTeacher()) .catch(() => connectIfTeacher());