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
|
<span class="icon">▶</span> Connect
|
||||||
</button>
|
</button>
|
||||||
<span id="connection-status" class="status-disconnected">Disconnected</span>
|
<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>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button id="btn-run" title="Upload and run code" disabled>
|
<button id="btn-run" title="Upload and run code" disabled>
|
||||||
|
|
@ -284,6 +287,34 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -7,12 +7,18 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"server": "node server/index.js"
|
"server": "node server/index.js",
|
||||||
|
"server:prod": "node server/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"blockly": "^11.2.1",
|
"blockly": "^11.2.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"esptool-js": "^0.5.0",
|
"esptool-js": "^0.5.0",
|
||||||
"express": "^5.2.1"
|
"express": "^5.2.1",
|
||||||
|
"mysql2": "^3.22.1",
|
||||||
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
|
|
|
||||||
43
readme.md
43
readme.md
|
|
@ -1 +1,44 @@
|
||||||
Realrobots.net blockly microcontroller IDE
|
Realrobots.net blockly microcontroller IDE
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Two processes run side by side:
|
||||||
|
|
||||||
|
1. `npm run server` — Node/Express backend (auth, WebSocket realtime, arduino-cli). Reads `.env` (see `.env.example`). Listens on `PORT` (default 3001).
|
||||||
|
2. `npm run dev` — Vite dev server on port 3000. Proxies `/blocks/api/*` and `/blocks/ws` to the backend.
|
||||||
|
|
||||||
|
Sign in is optional. Without login the IDE works as before. Once logged in as a student, your Blockly workspace is mirrored in realtime to any teacher viewing `/blocks/teacher.html`.
|
||||||
|
|
||||||
|
## Creating a teacher account
|
||||||
|
|
||||||
|
There is no public "create teacher" flow. Promote a user to teacher in MySQL after they register:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE users SET role = 'teacher' WHERE username = 'alice';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & deploy
|
||||||
|
|
||||||
|
`deploy.bat` builds to `dist/` and `scp`s it to `realrobots.net:~/blocks/` as before. The Node server now also needs to run on that host (e.g. via `pm2` or `systemd`) and the web server needs two proxy rules so the browser can reach it under the `/blocks/` base path:
|
||||||
|
|
||||||
|
- `/blocks/api/*` → `http://127.0.0.1:3001/api/*` (strip the `/blocks` prefix)
|
||||||
|
- `/blocks/ws` → `http://127.0.0.1:3001/ws` with WebSocket upgrade (strip the `/blocks` prefix)
|
||||||
|
|
||||||
|
Example nginx snippet:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /blocks/api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3001/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
location /blocks/ws {
|
||||||
|
proxy_pass http://127.0.0.1:3001/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After `deploy.bat`, restart the Node process (`pm2 restart esp32block-server` or equivalent).
|
||||||
|
|
|
||||||
|
|
@ -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 express from 'express';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
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 { 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 app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
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';
|
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) => {
|
app.get('/api/arduino/status', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,8 +71,6 @@ app.get('/api/arduino/boards', async (_req, res) => {
|
||||||
const { stdout } = await run(['board', 'list', '--format', 'json']);
|
const { stdout } = await run(['board', 'list', '--format', 'json']);
|
||||||
const raw = JSON.parse(stdout);
|
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 ports = Array.isArray(raw) ? raw : (raw.detected_ports || []);
|
||||||
|
|
||||||
const boards = 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 ───────────────────────────────────────────────
|
// ─── Start ───────────────────────────────────────────────
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
const server = createServer(app);
|
||||||
console.log(`Arduino CLI server listening on http://localhost:${PORT}`);
|
attachWsServer(server);
|
||||||
console.log(`Using CLI: ${CLI}`);
|
|
||||||
});
|
(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';
|
} from './ui/projectsDialog.js';
|
||||||
import { initRobotPanel, syncRobotPanelDevice } from './ui/robotPanel.js';
|
import { initRobotPanel, syncRobotPanelDevice } from './ui/robotPanel.js';
|
||||||
import { uploadHex, BOARDS } from './arduino/stk500.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';
|
import './style.css';
|
||||||
|
|
||||||
// ─── Blockly Workspace ───────────────────────────────────
|
// ─── 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) => {
|
workspace.addChangeListener((event) => {
|
||||||
if (event.isUiEvent) return;
|
if (event.isUiEvent) return;
|
||||||
saveWorkspace();
|
saveWorkspace();
|
||||||
|
scheduleBroadcast();
|
||||||
});
|
});
|
||||||
|
|
||||||
loadWorkspace();
|
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 ─────────────────────────────────────
|
// ─── Resize Handling ─────────────────────────────────────
|
||||||
|
|
||||||
function onResize() {
|
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-err { color: var(--red); }
|
||||||
.hex-upload-status.status-ok { color: var(--green); }
|
.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 { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
open: true,
|
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/',
|
base: '/blocks/',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
teacher: resolve(__dirname, 'teacher.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
// esptool-js uses dynamic import() for chip targets (e.g. esp32s3.js); include them
|
// esptool-js uses dynamic import() for chip targets (e.g. esp32s3.js); include them
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue