login
parent
843ac9d202
commit
7c8cd918e8
|
|
@ -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
|
||||
31
index.html
31
index.html
|
|
@ -22,6 +22,9 @@
|
|||
<span class="icon">▶</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">×</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>
|
||||
|
|
|
|||
10
package.json
10
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"
|
||||
|
|
|
|||
43
readme.md
43
readme.md
|
|
@ -1 +1,44 @@
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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()');
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
77
src/main.js
77
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() {
|
||||
|
|
|
|||
157
src/style.css
157
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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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());
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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">×</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">×</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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue