esp32blockly/src/teacher.js

456 lines
13 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');
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());