teacher view shows realtime updates, allows student account creation, pushes code

main
realrobotshk 2026-04-21 04:08:35 +00:00
parent 8d661307b9
commit be5b95b311
7 changed files with 291 additions and 16 deletions

View File

@ -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 (?, ?, ?)',

View File

@ -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) ───────────────────────────────

83
server/teacher.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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());

View File

@ -22,9 +22,19 @@
</header>
<main id="teacher-main">
<section id="teacher-student-admin" class="teacher-student-admin">
<h3>Student Accounts</h3>
<p>Create student users quickly. Default password is the same as username.</p>
<div class="teacher-student-admin-row">
<input type="text" id="teacher-new-student-username" placeholder="username" />
<button id="teacher-generate-username" type="button">Generate</button>
<button id="teacher-create-student" type="button">Create student</button>
</div>
<div id="teacher-create-student-status" class="login-status"></div>
</section>
<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>
<p>No students yet.</p>
<p class="teacher-empty-hint">Create students above. They appear online when signed in.</p>
</div>
<div id="teacher-grid" class="teacher-grid"></div>
</main>