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