From 7c8cd918e80a3805f37c7f0a89e9aa484131ed3d Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 21 Apr 2026 10:16:18 +0800 Subject: [PATCH] login --- .env.example | 14 ++ index.html | 31 ++++ package.json | 10 +- readme.md | 45 +++++- server/auth.js | 109 +++++++++++++ server/db.js | 92 +++++++++++ server/index.js | 51 ++++-- server/ws.js | 130 +++++++++++++++ src/auth/client.js | 107 ++++++++++++ src/main.js | 77 +++++++++ src/style.css | 157 ++++++++++++++++++ src/teacher.css | 194 ++++++++++++++++++++++ src/teacher.js | 348 ++++++++++++++++++++++++++++++++++++++++ src/ui/loginDialog.js | 129 +++++++++++++++ src/ws/studentClient.js | 88 ++++++++++ src/ws/teacherClient.js | 82 ++++++++++ teacher.html | 78 +++++++++ vite.config.js | 23 +++ 18 files changed, 1752 insertions(+), 13 deletions(-) create mode 100644 .env.example create mode 100644 server/auth.js create mode 100644 server/db.js create mode 100644 server/ws.js create mode 100644 src/auth/client.js create mode 100644 src/teacher.css create mode 100644 src/teacher.js create mode 100644 src/ui/loginDialog.js create mode 100644 src/ws/studentClient.js create mode 100644 src/ws/teacherClient.js create mode 100644 teacher.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e0de56 --- /dev/null +++ b/.env.example @@ -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 diff --git a/index.html b/index.html index dd4b622..0f21dfa 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,9 @@ Connect Disconnected + + +
+ + + + + + diff --git a/package.json b/package.json index 2ac6b0c..dd026f2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/readme.md b/readme.md index b6c2444..cab8f10 100644 --- a/readme.md +++ b/readme.md @@ -1 +1,44 @@ -Realrobots.net blockly microcontroller IDE \ No newline at end of file +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). diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..c335923 --- /dev/null +++ b/server/auth.js @@ -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; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..50f5fd0 --- /dev/null +++ b/server/db.js @@ -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()'); +} diff --git a/server/index.js b/server/index.js index 2a76ad6..0264f5f 100644 --- a/server/index.js +++ b/server/index.js @@ -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}`); + }); +})(); diff --git a/server/ws.js b/server/ws.js new file mode 100644 index 0000000..e5eaf55 --- /dev/null +++ b/server/ws.js @@ -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; +} diff --git a/src/auth/client.js b/src/auth/client.js new file mode 100644 index 0000000..06d6234 --- /dev/null +++ b/src/auth/client.js @@ -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(); +} diff --git a/src/main.js b/src/main.js index 5e5467c..e816852 100644 --- a/src/main.js +++ b/src/main.js @@ -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() { diff --git a/src/style.css b/src/style.css index d007881..9b45938 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } diff --git a/src/teacher.css b/src/teacher.css new file mode 100644 index 0000000..2d4a53e --- /dev/null +++ b/src/teacher.css @@ -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; } diff --git a/src/teacher.js b/src/teacher.js new file mode 100644 index 0000000..3268920 --- /dev/null +++ b/src/teacher.js @@ -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()); diff --git a/src/ui/loginDialog.js b/src/ui/loginDialog.js new file mode 100644 index 0000000..c1c56db --- /dev/null +++ b/src/ui/loginDialog.js @@ -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'); +} diff --git a/src/ws/studentClient.js b/src/ws/studentClient.js new file mode 100644 index 0000000..162c29e --- /dev/null +++ b/src/ws/studentClient.js @@ -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; +} diff --git a/src/ws/teacherClient.js b/src/ws/teacherClient.js new file mode 100644 index 0000000..39c1bb6 --- /dev/null +++ b/src/ws/teacherClient.js @@ -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; } +} diff --git a/teacher.html b/teacher.html new file mode 100644 index 0000000..9d4590d --- /dev/null +++ b/teacher.html @@ -0,0 +1,78 @@ + + + + + + RealRobots IDE — Teacher + + + + +
+
+ RealRobots IDE — Teacher + Connecting… + +
+
+ + Back to IDE + +
+
+ +
+ +
+
+ + + + + + + + + + + + + diff --git a/vite.config.js b/vite.config.js index af14a0a..a232c89 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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