From be5b95b311b151b20c4ed74150de790706b6339e Mon Sep 17 00:00:00 2001 From: realrobotshk Date: Tue, 21 Apr 2026 04:08:35 +0000 Subject: [PATCH] teacher view shows realtime updates, allows student account creation, pushes code --- server/db.js | 10 ++++ server/index.js | 2 + server/teacher.js | 83 ++++++++++++++++++++++++++++ server/ws.js | 9 ++++ src/teacher.css | 54 +++++++++++++++++++ src/teacher.js | 135 +++++++++++++++++++++++++++++++++++++++++----- teacher.html | 14 ++++- 7 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 server/teacher.js diff --git a/server/db.js b/server/db.js index cb39997..bf7bf24 100644 --- a/server/db.js +++ b/server/db.js @@ -76,6 +76,16 @@ export async function createUser({ username, passwordHash, role = 'student' }) { return { id: result.insertId, username, role }; } +export async function listStudents() { + const [rows] = await getPool().query( + `SELECT id, username, role, created_at + FROM users + WHERE role = 'student' + ORDER BY username ASC`, + ); + return rows; +} + export async function createSession({ token, userId, expiresAt }) { await getPool().query( 'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', diff --git a/server/index.js b/server/index.js index 81481a6..c5ff2f9 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; import authRouter from './auth.js'; import projectsRouter from './projects.js'; +import teacherRouter from './teacher.js'; import { bootstrap, purgeExpiredSessions } from './db.js'; import { attachWsServer } from './ws.js'; @@ -21,6 +22,7 @@ app.use(express.json({ limit: '2mb' })); app.use('/api/auth', authRouter); app.use('/api/projects', projectsRouter); +app.use('/api/teacher', teacherRouter); // ─── Helpers (Arduino CLI) ─────────────────────────────── diff --git a/server/teacher.js b/server/teacher.js new file mode 100644 index 0000000..11e6bfd --- /dev/null +++ b/server/teacher.js @@ -0,0 +1,83 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { requireTeacher } from './auth.js'; +import { findUserByUsername, createUser, listStudents } from './db.js'; + +const router = Router(); +const BCRYPT_ROUNDS = 8; +const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,64}$/; + +const WORDS_A = ['red', 'blue', 'green', 'happy', 'sunny', 'brave', 'tiny', 'swift', 'kind', 'bright']; +const WORDS_B = ['cat', 'dog', 'fox', 'duck', 'frog', 'bear', 'star', 'moon', 'kite', 'fish']; +const WORDS_C = ['jump', 'play', 'run', 'smile', 'spin', 'dance', 'wave', 'clap', 'sing', 'grow']; + +function sanitizeStudent(row) { + return { + id: row.id, + username: row.username, + role: row.role, + createdAt: row.created_at, + }; +} + +function randomItem(list) { + return list[Math.floor(Math.random() * list.length)]; +} + +function candidateUsername() { + const useThree = Math.random() < 0.45; + const first = randomItem(WORDS_A); + const second = randomItem(WORDS_B); + if (!useThree) return `${first}${second}`; + const third = randomItem(WORDS_C); + return `${first}${second}${third}`; +} + +async function generateUniqueUsername() { + for (let i = 0; i < 40; i += 1) { + const candidate = candidateUsername(); + const existing = await findUserByUsername(candidate); + if (!existing) return candidate; + } + return `${candidateUsername()}${Math.floor(100 + Math.random() * 900)}`; +} + +router.get('/students', requireTeacher, async (_req, res) => { + try { + const students = (await listStudents()).map(sanitizeStudent); + res.json({ students }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.get('/students/suggest-username', requireTeacher, async (_req, res) => { + try { + const username = await generateUniqueUsername(); + res.json({ username }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/students', requireTeacher, async (req, res) => { + let { username } = req.body || {}; + try { + username = username ? String(username).trim() : ''; + if (!username) username = await generateUniqueUsername(); + if (!USERNAME_RE.test(username)) { + return res.status(400).json({ error: 'username must be 3-64 chars, letters/digits/._-' }); + } + const existing = await findUserByUsername(username); + if (existing) return res.status(409).json({ error: 'username already taken' }); + + // Requested behavior: default password equals username. + const hash = await bcrypt.hash(username, BCRYPT_ROUNDS); + const user = await createUser({ username, passwordHash: hash, role: 'student' }); + res.json({ student: sanitizeStudent(user), defaultPassword: username }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/server/ws.js b/server/ws.js index e5eaf55..5904f94 100644 --- a/server/ws.js +++ b/server/ws.js @@ -106,6 +106,15 @@ export function attachWsServer(server) { send(ws, { type: 'push-code-result', ok: false, targetUserId: msg.targetUserId, error: 'Student not online' }); return; } + // Update the cached student state immediately so teacher dashboards + // reflect pushed code without waiting for the next student edit. + target.lastState = msg.state || null; + broadcastToTeachers({ + type: 'student-update', + userId: target.userId, + username: target.username, + state: target.lastState, + }); 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; diff --git a/src/teacher.css b/src/teacher.css index 2d4a53e..b7de12f 100644 --- a/src/teacher.css +++ b/src/teacher.css @@ -63,6 +63,56 @@ body.teacher-body { position: relative; } +.teacher-student-admin { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 14px; +} + +.teacher-student-admin h3 { + font-size: 14px; + margin: 0 0 4px; +} + +.teacher-student-admin p { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 12px; +} + +.teacher-student-admin-row { + display: flex; + gap: 6px; +} + +.teacher-student-admin-row input { + flex: 1; + min-width: 0; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 8px; + font-size: 12px; +} + +.teacher-student-admin-row button { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 12px; + cursor: pointer; +} + +.teacher-student-admin-row button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + .teacher-empty { text-align: center; color: var(--text-muted); @@ -113,6 +163,10 @@ body.teacher-body { color: var(--green); } +.student-tile-status.offline { + color: var(--text-muted); +} + .student-tile-blockly { width: 100%; height: 200px; diff --git a/src/teacher.js b/src/teacher.js index 3268920..9ba96b6 100644 --- a/src/teacher.js +++ b/src/teacher.js @@ -33,8 +33,12 @@ const modalClose = document.getElementById('student-modal-close'); const modalPushBtn = document.getElementById('student-push-btn'); const modalStatus = document.getElementById('student-modal-status'); const hiddenWsDiv = document.getElementById('teacher-hidden-ws'); +const createUsernameInput = document.getElementById('teacher-new-student-username'); +const createStudentBtn = document.getElementById('teacher-create-student'); +const generateUsernameBtn = document.getElementById('teacher-generate-username'); +const createStudentStatus = document.getElementById('teacher-create-student-status'); -// Map userId -> { username, state, tileEl, blockly, emptyLabel } +// Map userId -> { username, state, online, tileEl, blockly, emptyLabel } const students = new Map(); let modalBlockly = null; let focusedUserId = null; @@ -88,9 +92,10 @@ function setStatus(text, cls) { } function updateCount() { - const n = students.size; - countEl.textContent = `${n} student${n === 1 ? '' : 's'} online`; - emptyEl.classList.toggle('hidden', n > 0); + const total = students.size; + const online = Array.from(students.values()).filter((s) => s.online).length; + countEl.textContent = `${online} online / ${total} total`; + emptyEl.classList.toggle('hidden', total > 0); } function createTile(userId, username) { @@ -135,7 +140,12 @@ function createTile(userId, username) { move: { scrollbars: false, drag: false, wheel: false }, }); - return { tile, blockly, blocklyHost, emptyLabel, nameSpan }; + return { tile, blockly, blocklyHost, emptyLabel, nameSpan, statusSpan }; +} + +function updateTileOnlineState(entry) { + entry.statusSpan.textContent = entry.online ? '● live' : '● offline'; + entry.statusSpan.classList.toggle('offline', !entry.online); } function applyStateToWorkspace(state, blocklyWs) { @@ -152,7 +162,7 @@ function applyStateToWorkspace(state, blocklyWs) { } } -function upsertStudent({ userId, username, state }) { +function upsertStudent({ userId, username, state, online = false }) { let entry = students.get(userId); if (!entry) { const parts = createTile(userId, username); @@ -160,6 +170,7 @@ function upsertStudent({ userId, username, state }) { userId, username, state: null, + online, ...parts, }; students.set(userId, entry); @@ -168,6 +179,8 @@ function upsertStudent({ userId, username, state }) { entry.username = username; entry.nameSpan.textContent = username; } + entry.online = !!online; + updateTileOnlineState(entry); entry.state = state || null; const hasBlocks = applyStateToWorkspace(entry.state, entry.blockly); entry.emptyLabel.style.display = hasBlocks ? 'none' : ''; @@ -192,6 +205,50 @@ function clearAllStudents() { for (const id of Array.from(students.keys())) removeStudent(id); } +function setCreateStatus(text, isError = false) { + if (!createStudentStatus) return; + createStudentStatus.textContent = text || ''; + createStudentStatus.className = `login-status${isError ? ' status-err' : ' status-ok'}`; +} + +function apiUrl(path) { + const base = import.meta.env.BASE_URL || '/'; + const p = path.startsWith('/') ? path.slice(1) : path; + return (base.endsWith('/') ? base : base + '/') + p; +} + +async function teacherRequest(path, { method = 'GET', body } = {}) { + const token = getToken(); + const headers = { 'Content-Type': 'application/json' }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(apiUrl(path), { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { data = await res.json(); } catch { /* ignore */ } + if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`); + return data; +} + +async function loadAllStudentsFromServer() { + try { + const data = await teacherRequest('/api/teacher/students'); + for (const s of data.students || []) { + upsertStudent({ + userId: s.id, + username: s.username, + state: null, + online: false, + }); + } + updateCount(); + } catch { + // ignore; websocket still provides live students + } +} + function refreshModalFromEntry(entry) { if (!modalBlockly) return; modalTitle.textContent = entry.username; @@ -257,22 +314,29 @@ modalPushBtn.addEventListener('click', () => { function handleMessage(msg) { if (msg.type === 'roster') { - const incoming = new Set(); + for (const entry of students.values()) { + entry.online = false; + updateTileOnlineState(entry); + } 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); + upsertStudent({ ...s, online: true }); } + updateCount(); return; } if (msg.type === 'student-update') { - upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state }); + upsertStudent({ userId: msg.userId, username: msg.username, state: msg.state, online: true }); + updateCount(); return; } if (msg.type === 'student-leave') { - removeStudent(msg.userId); + const entry = students.get(msg.userId); + if (entry) { + entry.online = false; + updateTileOnlineState(entry); + updateCount(); + if (focusedUserId === msg.userId) refreshModalFromEntry(entry); + } return; } if (msg.type === 'push-code-result') { @@ -318,6 +382,7 @@ function connectIfTeacher() { } setStatus('Connecting…', 'status-disconnected'); + loadAllStudentsFromServer().catch(() => { /* ignore */ }); connectTeacher({ token, onOpen: () => { @@ -329,6 +394,40 @@ function connectIfTeacher() { }); } +async function suggestUsername() { + try { + const data = await teacherRequest('/api/teacher/students/suggest-username'); + if (createUsernameInput) createUsernameInput.value = data.username || ''; + setCreateStatus(''); + } catch (err) { + setCreateStatus(err.message || 'Could not generate username', true); + } +} + +async function createStudentQuick() { + const username = (createUsernameInput?.value || '').trim(); + createStudentBtn.disabled = true; + try { + const data = await teacherRequest('/api/teacher/students', { + method: 'POST', + body: username ? { username } : {}, + }); + if (createUsernameInput) createUsernameInput.value = data.student?.username || ''; + upsertStudent({ + userId: data.student.id, + username: data.student.username, + state: null, + online: false, + }); + updateCount(); + setCreateStatus(`Created ${data.student.username} (password: ${data.defaultPassword})`); + } catch (err) { + setCreateStatus(err.message || 'Could not create student', true); + } finally { + createStudentBtn.disabled = false; + } +} + function openLoginModal() { const overlay = document.getElementById('login-overlay'); if (overlay) overlay.classList.remove('hidden'); @@ -343,6 +442,14 @@ onAuthChange(() => connectIfTeacher()); ensureHiddenWorkspace(); updateCount(); +generateUsernameBtn?.addEventListener('click', () => { + suggestUsername().catch(() => { /* ignore */ }); +}); + +createStudentBtn?.addEventListener('click', () => { + createStudentQuick().catch(() => { /* ignore */ }); +}); + refreshMe() .then(() => connectIfTeacher()) .catch(() => connectIfTeacher()); diff --git a/teacher.html b/teacher.html index 9d4590d..2982d67 100644 --- a/teacher.html +++ b/teacher.html @@ -22,9 +22,19 @@
+
+

Student Accounts

+

Create student users quickly. Default password is the same as username.

+
+ + + +
+ +