349 lines
9.8 KiB
JavaScript
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());
|