main
Jake 2026-04-21 10:16:18 +08:00
parent 843ac9d202
commit 7c8cd918e8
18 changed files with 1752 additions and 13 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
# Copy to .env and fill in for your environment.
# The Node server (server/index.js) reads these at startup.
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=esp32block
MYSQL_PASSWORD=changeme
MYSQL_DATABASE=esp32block
# Port the Node server listens on (serves /api, /ws, and optionally the built static site)
PORT=3001
# Optional: path to arduino-cli if not on PATH
# ARDUINO_CLI=/usr/local/bin/arduino-cli

View File

@ -22,6 +22,9 @@
<span class="icon">&#9654;</span> Connect
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
<span id="user-badge" class="user-badge hidden"></span>
<button id="btn-teacher-view" class="hidden" title="Open the teacher dashboard">Teacher View</button>
<button id="btn-signin" title="Sign in with your account (optional)">Sign in</button>
</div>
<div class="toolbar-actions">
<button id="btn-run" title="Upload and run code" disabled>
@ -284,6 +287,34 @@
</div>
</div>
<!-- Login / Register overlay -->
<div id="login-overlay" class="hidden">
<div id="login-modal">
<div class="login-header">
<h3>Sign in</h3>
<button id="login-close" title="Close">&times;</button>
</div>
<p class="login-description">Signing in is optional. It lets teachers see your workspace live and push code to you in class.</p>
<div class="login-tabs">
<button type="button" id="login-tab-login" class="login-tab active">Sign in</button>
<button type="button" id="login-tab-register" class="login-tab">Create account</button>
</div>
<form id="login-form" class="login-form" autocomplete="off">
<label class="login-field-label" for="login-username">Username</label>
<input type="text" id="login-username" autocomplete="username" spellcheck="false" />
<label class="login-field-label" for="login-password">Password</label>
<input type="password" id="login-password" autocomplete="current-password" />
<div class="login-actions">
<button type="submit" id="login-submit">Sign in</button>
</div>
</form>
<div id="login-status" class="login-status"></div>
</div>
</div>
<!-- Toast for "teacher pushed blocks" notifications -->
<div id="push-toast" class="push-toast hidden"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -7,12 +7,18 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"server": "node server/index.js"
"server": "node server/index.js",
"server:prod": "node server/index.js"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"blockly": "^11.2.1",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"esptool-js": "^0.5.0",
"express": "^5.2.1"
"express": "^5.2.1",
"mysql2": "^3.22.1",
"ws": "^8.20.0"
},
"devDependencies": {
"vite": "^6.1.0"

View File

@ -1 +1,44 @@
Realrobots.net blockly microcontroller IDE
Realrobots.net blockly microcontroller IDE
## Development
Two processes run side by side:
1. `npm run server` — Node/Express backend (auth, WebSocket realtime, arduino-cli). Reads `.env` (see `.env.example`). Listens on `PORT` (default 3001).
2. `npm run dev` — Vite dev server on port 3000. Proxies `/blocks/api/*` and `/blocks/ws` to the backend.
Sign in is optional. Without login the IDE works as before. Once logged in as a student, your Blockly workspace is mirrored in realtime to any teacher viewing `/blocks/teacher.html`.
## Creating a teacher account
There is no public "create teacher" flow. Promote a user to teacher in MySQL after they register:
```sql
UPDATE users SET role = 'teacher' WHERE username = 'alice';
```
## Build & deploy
`deploy.bat` builds to `dist/` and `scp`s it to `realrobots.net:~/blocks/` as before. The Node server now also needs to run on that host (e.g. via `pm2` or `systemd`) and the web server needs two proxy rules so the browser can reach it under the `/blocks/` base path:
- `/blocks/api/*``http://127.0.0.1:3001/api/*` (strip the `/blocks` prefix)
- `/blocks/ws``http://127.0.0.1:3001/ws` with WebSocket upgrade (strip the `/blocks` prefix)
Example nginx snippet:
```nginx
location /blocks/api/ {
proxy_pass http://127.0.0.1:3001/api/;
proxy_set_header Host $host;
}
location /blocks/ws {
proxy_pass http://127.0.0.1:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
```
After `deploy.bat`, restart the Node process (`pm2 restart esp32block-server` or equivalent).

109
server/auth.js Normal file
View File

@ -0,0 +1,109 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
import {
findUserByUsername,
findUserById,
createUser,
createSession,
findSession,
deleteSession,
} from './db.js';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const BCRYPT_ROUNDS = 8;
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,64}$/;
function newToken() {
return randomBytes(24).toString('hex');
}
function sanitizeUser(u) {
return { id: u.id, username: u.username, role: u.role };
}
export async function validateToken(token) {
if (!token || typeof token !== 'string') return null;
const s = await findSession(token);
if (!s) return null;
return { id: s.user_id, username: s.username, role: s.role };
}
export function requireAuth(req, res, next) {
const h = req.get('authorization') || '';
const m = /^Bearer\s+(.+)$/i.exec(h);
if (!m) return res.status(401).json({ error: 'Not authenticated' });
validateToken(m[1])
.then((user) => {
if (!user) return res.status(401).json({ error: 'Invalid or expired session' });
req.user = user;
req.token = m[1];
next();
})
.catch((err) => res.status(500).json({ error: err.message }));
}
export function requireTeacher(req, res, next) {
requireAuth(req, res, () => {
if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Teacher only' });
next();
});
}
const router = Router();
router.post('/register', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password required' });
if (!USERNAME_RE.test(username)) return res.status(400).json({ error: 'username must be 3-64 chars, letters/digits/._-' });
if (password.length < 4) return res.status(400).json({ error: 'password must be at least 4 characters' });
try {
const existing = await findUserByUsername(username);
if (existing) return res.status(409).json({ error: 'username already taken' });
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const user = await createUser({ username, passwordHash: hash, role: 'student' });
const token = newToken();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await createSession({ token, userId: user.id, expiresAt });
res.json({ token, user: sanitizeUser(user) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password required' });
try {
const user = await findUserByUsername(username);
if (!user) return res.status(401).json({ error: 'Invalid username or password' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Invalid username or password' });
const token = newToken();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await createSession({ token, userId: user.id, expiresAt });
res.json({ token, user: sanitizeUser(user) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/logout', async (req, res) => {
const h = req.get('authorization') || '';
const m = /^Bearer\s+(.+)$/i.exec(h);
if (m) {
try { await deleteSession(m[1]); } catch { /* ignore */ }
}
res.json({ ok: true });
});
router.get('/me', requireAuth, async (req, res) => {
const fresh = await findUserById(req.user.id);
if (!fresh) return res.status(401).json({ error: 'User no longer exists' });
res.json({ user: sanitizeUser(fresh) });
});
export default router;

92
server/db.js Normal file
View File

@ -0,0 +1,92 @@
import mysql from 'mysql2/promise';
let pool = null;
export function getPool() {
if (pool) return pool;
pool = mysql.createPool({
host: process.env.MYSQL_HOST || 'localhost',
port: Number(process.env.MYSQL_PORT) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'esp32block',
waitForConnections: true,
connectionLimit: 8,
queueLimit: 0,
charset: 'utf8mb4',
});
return pool;
}
export async function bootstrap() {
const db = getPool();
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('student','teacher') NOT NULL DEFAULT 'student',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
await db.query(`
CREATE TABLE IF NOT EXISTS sessions (
token CHAR(48) PRIMARY KEY,
user_id INT NOT NULL,
expires_at DATETIME NOT NULL,
INDEX idx_sessions_user (user_id),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`);
}
export async function findUserByUsername(username) {
const [rows] = await getPool().query(
'SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1',
[username],
);
return rows[0] || null;
}
export async function findUserById(id) {
const [rows] = await getPool().query(
'SELECT id, username, role FROM users WHERE id = ? LIMIT 1',
[id],
);
return rows[0] || null;
}
export async function createUser({ username, passwordHash, role = 'student' }) {
const [result] = await getPool().query(
'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[username, passwordHash, role],
);
return { id: result.insertId, username, role };
}
export async function createSession({ token, userId, expiresAt }) {
await getPool().query(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
[token, userId, expiresAt],
);
}
export async function findSession(token) {
const [rows] = await getPool().query(
`SELECT s.token, s.user_id, s.expires_at, u.username, u.role
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > NOW()
LIMIT 1`,
[token],
);
return rows[0] || null;
}
export async function deleteSession(token) {
await getPool().query('DELETE FROM sessions WHERE token = ?', [token]);
}
export async function purgeExpiredSessions() {
await getPool().query('DELETE FROM sessions WHERE expires_at <= NOW()');
}

View File

@ -1,15 +1,26 @@
import 'dotenv/config';
import express from 'express';
import { createServer } from 'node:http';
import { execFile } from 'node:child_process';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import authRouter from './auth.js';
import { bootstrap, purgeExpiredSessions } from './db.js';
import { attachWsServer } from './ws.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '2mb' }));
// ─── Helpers ─────────────────────────────────────────────
// ─── Auth ────────────────────────────────────────────────
app.use('/api/auth', authRouter);
// ─── Helpers (Arduino CLI) ───────────────────────────────
const CLI = process.env.ARDUINO_CLI || 'arduino-cli';
@ -43,7 +54,7 @@ async function cleanupDir(dir) {
}
}
// ─── Routes ──────────────────────────────────────────────
// ─── Routes (Arduino CLI) ────────────────────────────────
app.get('/api/arduino/status', async (_req, res) => {
try {
@ -60,8 +71,6 @@ app.get('/api/arduino/boards', async (_req, res) => {
const { stdout } = await run(['board', 'list', '--format', 'json']);
const raw = JSON.parse(stdout);
// arduino-cli >=0.35 returns { detected_ports: [...] }
// older versions return a flat array
const ports = Array.isArray(raw) ? raw : (raw.detected_ports || []);
const boards = ports
@ -129,9 +138,31 @@ app.post('/api/arduino/upload', async (req, res) => {
}
});
// ─── Static (built frontend) ─────────────────────────────
// Serve the Vite build output. In dev, the Vite server handles assets and
// proxies /api + /ws to this server instead (see vite.config.js).
const DIST_DIR = resolve(__dirname, '..', 'dist');
app.use(express.static(DIST_DIR));
// ─── Start ───────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Arduino CLI server listening on http://localhost:${PORT}`);
console.log(`Using CLI: ${CLI}`);
});
const server = createServer(app);
attachWsServer(server);
(async () => {
try {
await bootstrap();
purgeExpiredSessions().catch(() => { /* ignore */ });
setInterval(() => purgeExpiredSessions().catch(() => {}), 60 * 60 * 1000);
} catch (err) {
console.error('Database bootstrap failed:', err.message);
console.error('Auth + realtime features will be unavailable. Check MYSQL_* env vars.');
}
server.listen(PORT, () => {
console.log(`esp32block server listening on http://localhost:${PORT}`);
console.log(` static: ${DIST_DIR}`);
console.log(` arduino CLI: ${CLI}`);
});
})();

130
server/ws.js Normal file
View File

@ -0,0 +1,130 @@
import { WebSocketServer } from 'ws';
import { URL } from 'node:url';
import { validateToken } from './auth.js';
const studentsById = new Map();
const teachers = new Set();
function send(ws, obj) {
if (ws.readyState !== ws.OPEN) return;
try {
ws.send(JSON.stringify(obj));
} catch {
/* ignore */
}
}
function broadcastToTeachers(obj) {
for (const tws of teachers) send(tws, obj);
}
function rosterSnapshot() {
return Array.from(studentsById.values()).map((s) => ({
userId: s.userId,
username: s.username,
state: s.lastState,
}));
}
export function attachWsServer(server) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', async (req, socket, head) => {
let user = null;
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws') {
socket.destroy();
return;
}
const token = url.searchParams.get('token');
user = await validateToken(token);
} catch {
user = null;
}
if (!user) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.user = user;
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws) => {
const { id: userId, username, role } = ws.user;
if (role === 'teacher') {
teachers.add(ws);
send(ws, { type: 'roster', students: rosterSnapshot() });
} else {
const prev = studentsById.get(userId);
if (prev && prev.ws !== ws) {
try { prev.ws.close(4000, 'Replaced by new connection'); } catch { /* ignore */ }
}
const entry = { userId, username, ws, lastState: prev?.lastState || null };
studentsById.set(userId, entry);
broadcastToTeachers({
type: 'student-update',
userId,
username,
state: entry.lastState,
});
}
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (!msg || typeof msg !== 'object') return;
if (role === 'student') {
if (msg.type === 'workspace') {
const entry = studentsById.get(userId);
if (entry) entry.lastState = msg.state || null;
broadcastToTeachers({
type: 'student-update',
userId,
username,
state: msg.state || null,
});
}
return;
}
if (role === 'teacher') {
if (msg.type === 'list') {
send(ws, { type: 'roster', students: rosterSnapshot() });
return;
}
if (msg.type === 'push-code') {
const target = studentsById.get(Number(msg.targetUserId));
if (!target) {
send(ws, { type: 'push-code-result', ok: false, targetUserId: msg.targetUserId, error: 'Student not online' });
return;
}
send(target.ws, { type: 'push-code', state: msg.state || null, from: ws.user.username });
send(ws, { type: 'push-code-result', ok: true, targetUserId: target.userId });
return;
}
}
});
ws.on('close', () => {
if (role === 'teacher') {
teachers.delete(ws);
} else {
const entry = studentsById.get(userId);
if (entry && entry.ws === ws) {
studentsById.delete(userId);
broadcastToTeachers({ type: 'student-leave', userId });
}
}
});
});
return wss;
}

107
src/auth/client.js Normal file
View File

@ -0,0 +1,107 @@
const STORAGE_KEY = 'esp32block_auth';
let current = null;
const listeners = new Set();
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed?.token || !parsed?.user) return null;
return parsed;
} catch {
return null;
}
}
function persist(value) {
current = value;
if (value) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
} else {
localStorage.removeItem(STORAGE_KEY);
}
for (const fn of listeners) {
try { fn(current); } catch { /* ignore */ }
}
}
export function getAuth() {
if (current !== null) return current;
current = load();
return current;
}
export function onAuthChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
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 request(path, { method = 'GET', body, auth = false } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (auth) {
const a = getAuth();
if (a?.token) headers['Authorization'] = `Bearer ${a.token}`;
}
const res = await fetch(apiUrl(path), {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
let data = null;
try { data = await res.json(); } catch { /* non-json */ }
if (!res.ok) {
const msg = data?.error || `HTTP ${res.status}`;
throw new Error(msg);
}
return data;
}
export async function login(username, password) {
const data = await request('/api/auth/login', { method: 'POST', body: { username, password } });
persist({ token: data.token, user: data.user });
return data.user;
}
export async function register(username, password) {
const data = await request('/api/auth/register', { method: 'POST', body: { username, password } });
persist({ token: data.token, user: data.user });
return data.user;
}
export async function logout() {
try { await request('/api/auth/logout', { method: 'POST', auth: true }); } catch { /* ignore */ }
persist(null);
}
export async function refreshMe() {
const a = getAuth();
if (!a?.token) return null;
try {
const data = await request('/api/auth/me', { auth: true });
persist({ token: a.token, user: data.user });
return data.user;
} catch {
persist(null);
return null;
}
}
export function getToken() {
return getAuth()?.token || null;
}
export function getUser() {
return getAuth()?.user || null;
}
export function isLoggedIn() {
return !!getToken();
}

View File

@ -37,6 +37,9 @@ import {
} from './ui/projectsDialog.js';
import { initRobotPanel, syncRobotPanelDevice } from './ui/robotPanel.js';
import { uploadHex, BOARDS } from './arduino/stk500.js';
import { initLoginDialog } from './ui/loginDialog.js';
import { getUser, getToken, refreshMe, onAuthChange } from './auth/client.js';
import { connectStudent, disconnectStudent, sendWorkspace } from './ws/studentClient.js';
import './style.css';
// ─── Blockly Workspace ───────────────────────────────────
@ -134,13 +137,87 @@ function loadWorkspace() {
}
}
let suppressBroadcast = false;
let broadcastTimer = null;
function scheduleBroadcast() {
if (suppressBroadcast) return;
if (broadcastTimer) return;
broadcastTimer = setTimeout(() => {
broadcastTimer = null;
if (suppressBroadcast) return;
try {
const state = Blockly.serialization.workspaces.save(workspace);
sendWorkspace(state);
} catch { /* ignore */ }
}, 500);
}
workspace.addChangeListener((event) => {
if (event.isUiEvent) return;
saveWorkspace();
scheduleBroadcast();
});
loadWorkspace();
// ─── Push-code toast helper ──────────────────────────────
const pushToast = document.getElementById('push-toast');
let pushToastTimer = null;
function showPushToast(message) {
if (!pushToast) return;
pushToast.textContent = message;
pushToast.classList.remove('hidden');
if (pushToastTimer) clearTimeout(pushToastTimer);
pushToastTimer = setTimeout(() => pushToast.classList.add('hidden'), 3500);
}
function applyPushedState(state, fromUser) {
if (!state) return;
suppressBroadcast = true;
try {
Blockly.serialization.workspaces.load(state, workspace);
updateCodePreview();
saveWorkspace();
} catch (err) {
appendToTerminal(`\nFailed to apply pushed blocks: ${err.message}\n`);
} finally {
setTimeout(() => { suppressBroadcast = false; }, 250);
}
showPushToast(`Teacher${fromUser ? ` (${fromUser})` : ''} pushed new blocks to your workspace.`);
}
// ─── Auth / realtime wiring ──────────────────────────────
function syncRealtimeConnection() {
const user = getUser();
const token = getToken();
if (!user || !token) {
disconnectStudent();
return;
}
if (user.role !== 'student') {
disconnectStudent();
return;
}
connectStudent({
token,
onOpen: () => {
try {
const state = Blockly.serialization.workspaces.save(workspace);
sendWorkspace(state);
} catch { /* ignore */ }
},
onPush: (state, from) => applyPushedState(state, from),
});
}
initLoginDialog();
onAuthChange(() => syncRealtimeConnection());
refreshMe().then(() => syncRealtimeConnection()).catch(() => { /* ignore */ });
// ─── Resize Handling ─────────────────────────────────────
function onResize() {

View File

@ -1444,3 +1444,160 @@ html, body {
}
.hex-upload-status.status-err { color: var(--red); }
.hex-upload-status.status-ok { color: var(--green); }
/* --- Login / Auth --- */
.user-badge {
font-size: 12px;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: var(--radius);
background: var(--bg-surface);
border: 1px solid var(--border);
}
#login-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#login-modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 28px;
width: 400px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 14px;
}
.login-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.login-header h3 {
margin: 0;
color: var(--accent);
font-size: 16px;
}
.login-header button {
background: none;
border: none;
color: var(--text-muted);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.login-header button:hover { color: var(--text-primary); }
.login-description {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.login-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.login-tab {
background: none;
border: none;
color: var(--text-secondary);
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.login-tab:hover { color: var(--text-primary); }
.login-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.login-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.login-field-label {
font-size: 12px;
color: var(--text-muted);
}
.login-form input {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
font-size: 13px;
outline: none;
}
.login-form input:focus { border-color: var(--accent); }
.login-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.login-actions button {
background: var(--accent);
color: var(--bg-toolbar);
border: none;
border-radius: var(--radius);
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.login-actions button:hover:not(:disabled) { background: var(--accent-hover); }
.login-actions button:disabled { opacity: 0.6; cursor: default; }
.login-status {
font-size: 12px;
min-height: 18px;
color: var(--text-muted);
}
.login-status.status-err { color: var(--red); }
.login-status.status-ok { color: var(--green); }
/* Toast shown when a teacher pushes blocks to a student */
.push-toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2000;
background: var(--bg-surface);
border: 1px solid var(--accent);
color: var(--text-primary);
padding: 10px 16px;
border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
font-size: 13px;
max-width: 320px;
transition: opacity 0.25s ease;
}
.push-toast.hidden { display: none; }

194
src/teacher.css Normal file
View File

@ -0,0 +1,194 @@
html, body.teacher-body {
height: 100%;
margin: 0;
overflow: hidden;
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
body.teacher-body {
display: flex;
flex-direction: column;
}
#teacher-toolbar {
height: var(--toolbar-height);
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
gap: 12px;
flex: 0 0 auto;
}
#teacher-toolbar .toolbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
#teacher-toolbar a,
#teacher-toolbar button {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
text-decoration: none;
}
#teacher-toolbar a:hover,
#teacher-toolbar button:hover {
border-color: var(--accent);
color: var(--accent);
}
.teacher-count {
font-size: 12px;
color: var(--text-muted);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
}
#teacher-main {
flex: 1 1 auto;
overflow: auto;
padding: 16px;
position: relative;
}
.teacher-empty {
text-align: center;
color: var(--text-muted);
padding: 64px 16px;
}
.teacher-empty-hint { font-size: 12px; margin-top: 8px; }
.teacher-empty.hidden { display: none; }
.teacher-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 14px;
}
.student-tile {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.student-tile:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.student-tile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.student-tile-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
}
.student-tile-status {
font-size: 11px;
color: var(--green);
}
.student-tile-blockly {
width: 100%;
height: 200px;
position: relative;
background: var(--bg-primary);
}
.student-tile-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 12px;
pointer-events: none;
}
#teacher-hidden-ws {
position: fixed;
left: -10000px;
top: -10000px;
width: 800px;
height: 600px;
}
#student-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#student-modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px;
width: 900px;
max-width: 92vw;
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 12px;
}
.student-modal-preview {
flex: 1 1 auto;
min-height: 420px;
height: 60vh;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.student-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.student-modal-actions button {
background: var(--accent);
color: var(--bg-toolbar);
border: none;
border-radius: var(--radius);
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.student-modal-actions button:hover:not(:disabled) { background: var(--accent-hover); }
.student-modal-actions button:disabled { opacity: 0.6; cursor: default; }

348
src/teacher.js Normal file
View File

@ -0,0 +1,348 @@
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());

129
src/ui/loginDialog.js Normal file
View File

@ -0,0 +1,129 @@
import { login, register, logout, getUser, onAuthChange } from '../auth/client.js';
let overlay, modal, statusEl, tabLogin, tabRegister, form, submitBtn, usernameInput, passwordInput, closeBtn;
let badge, signInBtn, teacherLink;
let mode = 'login';
function setStatus(msg, ok = false) {
if (!statusEl) return;
statusEl.textContent = msg || '';
statusEl.className = 'login-status ' + (msg ? (ok ? 'status-ok' : 'status-err') : '');
}
function openOverlay(startMode = 'login') {
mode = startMode;
updateTabs();
setStatus('');
overlay.classList.remove('hidden');
setTimeout(() => usernameInput?.focus(), 0);
}
function closeOverlay() {
overlay.classList.add('hidden');
}
function updateTabs() {
tabLogin.classList.toggle('active', mode === 'login');
tabRegister.classList.toggle('active', mode === 'register');
submitBtn.textContent = mode === 'login' ? 'Sign in' : 'Create account';
}
function renderAuthState() {
const user = getUser();
if (badge) {
if (user) {
badge.classList.remove('hidden');
badge.textContent = `${user.username}${user.role === 'teacher' ? ' (teacher)' : ''}`;
} else {
badge.classList.add('hidden');
badge.textContent = '';
}
}
if (signInBtn) {
if (user) {
signInBtn.textContent = 'Sign out';
signInBtn.title = `Sign out (${user.username})`;
} else {
signInBtn.textContent = 'Sign in';
signInBtn.title = 'Sign in with your account (optional)';
}
}
if (teacherLink) {
teacherLink.classList.toggle('hidden', !user || user.role !== 'teacher');
}
}
async function submit(e) {
e?.preventDefault();
const u = usernameInput.value.trim();
const p = passwordInput.value;
if (!u || !p) {
setStatus('Enter a username and password.');
return;
}
submitBtn.disabled = true;
setStatus('Working…');
try {
if (mode === 'login') await login(u, p);
else await register(u, p);
setStatus('Signed in!', true);
passwordInput.value = '';
setTimeout(closeOverlay, 300);
} catch (err) {
setStatus(err.message || 'Failed');
} finally {
submitBtn.disabled = false;
}
}
export function initLoginDialog({ onAuthChanged } = {}) {
overlay = document.getElementById('login-overlay');
modal = document.getElementById('login-modal');
statusEl = document.getElementById('login-status');
tabLogin = document.getElementById('login-tab-login');
tabRegister = document.getElementById('login-tab-register');
form = document.getElementById('login-form');
submitBtn = document.getElementById('login-submit');
usernameInput = document.getElementById('login-username');
passwordInput = document.getElementById('login-password');
closeBtn = document.getElementById('login-close');
badge = document.getElementById('user-badge');
signInBtn = document.getElementById('btn-signin');
teacherLink = document.getElementById('btn-teacher-view');
if (!overlay || !form) return;
tabLogin.addEventListener('click', () => { mode = 'login'; updateTabs(); setStatus(''); });
tabRegister.addEventListener('click', () => { mode = 'register'; updateTabs(); setStatus(''); });
form.addEventListener('submit', submit);
closeBtn.addEventListener('click', closeOverlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeOverlay(); });
if (signInBtn) {
signInBtn.addEventListener('click', async () => {
if (getUser()) {
await logout();
} else {
openOverlay('login');
}
});
}
if (teacherLink) {
teacherLink.addEventListener('click', () => {
const base = import.meta.env.BASE_URL || '/';
window.location.href = base + 'teacher.html';
});
}
onAuthChange(() => {
renderAuthState();
try { onAuthChanged?.(getUser()); } catch { /* ignore */ }
});
renderAuthState();
}
export function openLogin() {
if (overlay) openOverlay('login');
}

88
src/ws/studentClient.js Normal file
View File

@ -0,0 +1,88 @@
let socket = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
let currentToken = null;
let currentHandlers = null;
function wsUrl(token) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = import.meta.env.BASE_URL || '/';
const path = (base.endsWith('/') ? base : base + '/') + 'ws';
// Path may start with /blocks/ in production. BASE_URL is '/blocks/' or '/'.
const normalized = path.startsWith('/') ? path : '/' + path;
return `${proto}//${location.host}${normalized}?token=${encodeURIComponent(token)}`;
}
export function sendWorkspace(state) {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
try {
socket.send(JSON.stringify({ type: 'workspace', state }));
} catch { /* ignore */ }
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (currentToken && currentHandlers) openSocket();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
}
function openSocket() {
try {
socket = new WebSocket(wsUrl(currentToken));
} catch (err) {
scheduleReconnect();
return;
}
socket.addEventListener('open', () => {
reconnectDelay = 1000;
currentHandlers?.onOpen?.();
});
socket.addEventListener('message', (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'push-code') {
currentHandlers?.onPush?.(msg.state, msg.from);
}
});
socket.addEventListener('close', () => {
socket = null;
currentHandlers?.onClose?.();
if (currentToken) scheduleReconnect();
});
socket.addEventListener('error', () => {
try { socket?.close(); } catch { /* ignore */ }
});
}
export function connectStudent({ token, onPush, onOpen, onClose }) {
disconnectStudent();
currentToken = token;
currentHandlers = { onPush, onOpen, onClose };
reconnectDelay = 1000;
openSocket();
}
export function disconnectStudent() {
currentToken = null;
currentHandlers = null;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (socket) {
try { socket.close(); } catch { /* ignore */ }
socket = null;
}
}
export function isStudentConnected() {
return !!socket && socket.readyState === WebSocket.OPEN;
}

82
src/ws/teacherClient.js Normal file
View File

@ -0,0 +1,82 @@
let socket = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
let currentToken = null;
let currentHandlers = null;
function wsUrl(token) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const base = import.meta.env.BASE_URL || '/';
const path = (base.endsWith('/') ? base : base + '/') + 'ws';
const normalized = path.startsWith('/') ? path : '/' + path;
return `${proto}//${location.host}${normalized}?token=${encodeURIComponent(token)}`;
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (currentToken && currentHandlers) openSocket();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
}
function openSocket() {
try {
socket = new WebSocket(wsUrl(currentToken));
} catch {
scheduleReconnect();
return;
}
socket.addEventListener('open', () => {
reconnectDelay = 1000;
currentHandlers?.onOpen?.();
});
socket.addEventListener('message', (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (!msg || typeof msg !== 'object') return;
currentHandlers?.onMessage?.(msg);
});
socket.addEventListener('close', () => {
socket = null;
currentHandlers?.onClose?.();
if (currentToken) scheduleReconnect();
});
socket.addEventListener('error', () => {
try { socket?.close(); } catch { /* ignore */ }
});
}
export function connectTeacher({ token, onMessage, onOpen, onClose }) {
disconnectTeacher();
currentToken = token;
currentHandlers = { onMessage, onOpen, onClose };
reconnectDelay = 1000;
openSocket();
}
export function disconnectTeacher() {
currentToken = null;
currentHandlers = null;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (socket) {
try { socket.close(); } catch { /* ignore */ }
socket = null;
}
}
export function sendTeacher(obj) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false;
try {
socket.send(JSON.stringify(obj));
return true;
} catch { return false; }
}

78
teacher.html Normal file
View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RealRobots IDE — Teacher</title>
<link rel="stylesheet" href="/src/style.css" />
<link rel="stylesheet" href="/src/teacher.css" />
</head>
<body class="teacher-body">
<header id="teacher-toolbar">
<div class="toolbar-left">
<span class="app-title">RealRobots IDE — Teacher</span>
<span id="teacher-status" class="status-disconnected">Connecting…</span>
<span id="teacher-count" class="teacher-count"></span>
</div>
<div class="toolbar-actions">
<span id="user-badge" class="user-badge"></span>
<a href="./" id="teacher-back" title="Back to IDE">Back to IDE</a>
<button id="teacher-logout" title="Sign out">Sign out</button>
</div>
</header>
<main id="teacher-main">
<div id="teacher-empty" class="teacher-empty hidden">
<p>No students online right now.</p>
<p class="teacher-empty-hint">Students show up here when they sign in on the main IDE.</p>
</div>
<div id="teacher-grid" class="teacher-grid"></div>
</main>
<!-- Focused student modal -->
<div id="student-overlay" class="hidden">
<div id="student-modal">
<div class="login-header">
<h3 id="student-modal-title">Student</h3>
<button id="student-modal-close" title="Close">&times;</button>
</div>
<div id="student-modal-preview" class="student-modal-preview"></div>
<div class="student-modal-actions">
<button id="student-push-btn">Push my current blocks to this student</button>
</div>
<div id="student-modal-status" class="login-status"></div>
</div>
</div>
<!-- Hidden workspace the teacher edits in the main IDE is loaded here via localStorage
so we can serialize and push it to students. -->
<div id="teacher-hidden-ws" aria-hidden="true"></div>
<!-- Login modal reused so teachers that aren't signed in can sign in here -->
<div id="login-overlay" class="hidden">
<div id="login-modal">
<div class="login-header">
<h3>Sign in</h3>
<button id="login-close" title="Close">&times;</button>
</div>
<p class="login-description">Sign in with your teacher account to see students live.</p>
<div class="login-tabs">
<button type="button" id="login-tab-login" class="login-tab active">Sign in</button>
<button type="button" id="login-tab-register" class="login-tab">Create account</button>
</div>
<form id="login-form" class="login-form" autocomplete="off">
<label class="login-field-label" for="login-username">Username</label>
<input type="text" id="login-username" autocomplete="username" spellcheck="false" />
<label class="login-field-label" for="login-password">Password</label>
<input type="password" id="login-password" autocomplete="current-password" />
<div class="login-actions">
<button type="submit" id="login-submit">Sign in</button>
</div>
</form>
<div id="login-status" class="login-status"></div>
</div>
</div>
<script type="module" src="/src/teacher.js"></script>
</body>
</html>

View File

@ -1,13 +1,36 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
server: {
port: 3000,
open: true,
// Proxy /api and /ws to the Node backend during dev.
// BASE_URL is '/blocks/' in production but '/' in dev (see `base` below for prod builds).
proxy: {
// Frontend uses BASE_URL (/blocks/) prefix; strip it before forwarding to Node.
'/blocks/api': {
target: 'http://localhost:3001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/blocks/, ''),
},
'/blocks/ws': {
target: 'ws://localhost:3001',
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/blocks/, ''),
},
},
},
base: '/blocks/',
build: {
outDir: 'dist',
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
teacher: resolve(__dirname, 'teacher.html'),
},
},
},
optimizeDeps: {
// esptool-js uses dynamic import() for chip targets (e.g. esp32s3.js); include them