esp32blockly/src/teacher.js

349 lines
9.8 KiB
JavaScript

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());