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'); const createUsernameInput = document.getElementById('teacher-new-student-username'); const createStudentBtn = document.getElementById('teacher-create-student'); const generateUsernameBtn = document.getElementById('teacher-generate-username'); const createStudentStatus = document.getElementById('teacher-create-student-status'); // Map userId -> { username, state, online, 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 total = students.size; const online = Array.from(students.values()).filter((s) => s.online).length; countEl.textContent = `${online} online / ${total} total`; emptyEl.classList.toggle('hidden', total > 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, statusSpan }; } function updateTileOnlineState(entry) { entry.statusSpan.textContent = entry.online ? '● live' : '● offline'; entry.statusSpan.classList.toggle('offline', !entry.online); } 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, online = false }) { let entry = students.get(userId); if (!entry) { const parts = createTile(userId, username); entry = { userId, username, state: null, online, ...parts, }; students.set(userId, entry); updateCount(); } else if (entry.username !== username) { entry.username = username; entry.nameSpan.textContent = username; } entry.online = !!online; updateTileOnlineState(entry); 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 setCreateStatus(text, isError = false) { if (!createStudentStatus) return; createStudentStatus.textContent = text || ''; createStudentStatus.className = `login-status${isError ? ' status-err' : ' status-ok'}`; } function apiUrl(path) { const base = import.meta.env.BASE_URL || '/'; const p = path.startsWith('/') ? path.slice(1) : path; return (base.endsWith('/') ? base : base + '/') + p; } async function teacherRequest(path, { method = 'GET', body } = {}) { const token = getToken(); const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(apiUrl(path), { method, headers, body: body ? JSON.stringify(body) : undefined, }); let data = null; try { data = await res.json(); } catch { /* ignore */ } if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`); return data; } async function loadAllStudentsFromServer() { try { const data = await teacherRequest('/api/teacher/students'); for (const s of data.students || []) { upsertStudent({ userId: s.id, username: s.username, state: null, online: false, }); } updateCount(); } catch { // ignore; websocket still provides live students } } 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') { for (const entry of students.values()) { entry.online = false; updateTileOnlineState(entry); } for (const s of msg.students || []) { upsertStudent({ ...s, online: true }); } updateCount(); return; } if (msg.type === 'student-update') { upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state, online: true }); updateCount(); return; } if (msg.type === 'student-leave') { const entry = students.get(msg.userId); if (entry) { entry.online = false; updateTileOnlineState(entry); updateCount(); if (focusedUserId === msg.userId) refreshModalFromEntry(entry); } 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'); loadAllStudentsFromServer().catch(() => { /* ignore */ }); connectTeacher({ token, onOpen: () => { setStatus('Connected', 'status-connected'); sendTeacher({ type: 'list' }); }, onClose: () => setStatus('Disconnected', 'status-disconnected'), onMessage: handleMessage, }); } async function suggestUsername() { try { const data = await teacherRequest('/api/teacher/students/suggest-username'); if (createUsernameInput) createUsernameInput.value = data.username || ''; setCreateStatus(''); } catch (err) { setCreateStatus(err.message || 'Could not generate username', true); } } async function createStudentQuick() { const username = (createUsernameInput?.value || '').trim(); createStudentBtn.disabled = true; try { const data = await teacherRequest('/api/teacher/students', { method: 'POST', body: username ? { username } : {}, }); if (createUsernameInput) createUsernameInput.value = data.student?.username || ''; upsertStudent({ userId: data.student.id, username: data.student.username, state: null, online: false, }); updateCount(); setCreateStatus(`Created ${data.student.username} (password: ${data.defaultPassword})`); } catch (err) { setCreateStatus(err.message || 'Could not create student', true); } finally { createStudentBtn.disabled = false; } } 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(); generateUsernameBtn?.addEventListener('click', () => { suggestUsername().catch(() => { /* ignore */ }); }); createStudentBtn?.addEventListener('click', () => { createStudentQuick().catch(() => { /* ignore */ }); }); refreshMe() .then(() => connectIfTeacher()) .catch(() => connectIfTeacher());