feat: WebSocket real-time + rAF render gate + guest board + screen picker
Classroom performance: - WebSocket server (ws-server.js) for low-latency cursor & stroke preview Replaces HTTP POST per event → eliminates per-message auth overhead Session member cache (30s TTL) avoids SQLite query per WS message Fallback to HTTP POST when WS not connected - Cursor throttle reduced 100ms → 33ms (~30fps) - Stroke preview throttle reduced 50ms → 20ms - whiteboard.js: render() is now rAF-gated (_doRender/_rafPending) Multiple render() calls within one frame collapse into one repaint document.hidden check — zero CPU when tab is in background visibilitychange listener restores canvas on tab focus Guest board: - guestClassroom.js route: public token-based read-only access - guest-board.html: name entry + read-only whiteboard + SSE - SSE: addGuestClient/removeGuestClient/emitToGuests Screen share picker: - Discord-style modal with tab switching (screen/window/tab) - Live video preview before confirming share - useExistingScreenStream() in ClassroomRTC Fullscreen exit overlay: - #cr-fs-exit-overlay button inside cr-board-wrap - Visible only via CSS :fullscreen selector (touchpad users) File sharing from library: - Teacher picks file from library, sends as styled card in chat - crDownloadLibraryFile() fetches with Bearer auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,9 +30,16 @@ const auth = [authMiddleware];
|
||||
const chatLimiter = rateLimit({ windowMs: 5_000, max: 5, message: 'Слишком много сообщений, подождите' });
|
||||
|
||||
// Template library — MUST be before /:id to avoid shadowing
|
||||
router.get('/admin/active', ...teacher, c.adminGetActiveSessions);
|
||||
router.get('/admin/sessions', ...teacher, c.adminGetAllSessions);
|
||||
router.get('/admin/teachers-list', ...teacher, c.adminGetTeachersList);
|
||||
router.get('/templates', ...teacher, c.getTemplates);
|
||||
router.delete('/templates/:tid', ...teacher, c.deleteTemplate);
|
||||
|
||||
// History — MUST be before /:id to avoid shadowing
|
||||
router.get('/my/history', ...auth, c.getMyHistory);
|
||||
router.get('/class/:classId/history', ...auth, c.getClassHistory);
|
||||
|
||||
// Session lifecycle
|
||||
router.post('/', ...teacher, c.createSession);
|
||||
router.get('/online-students', ...teacher, c.getOnlineStudents);
|
||||
@@ -65,9 +72,13 @@ router.patch('/:id/strokes/:strokeId', ...auth, c.updateStroke);
|
||||
router.post('/:id/stroke-preview', ...auth, c.previewStroke);
|
||||
|
||||
// Multi-page
|
||||
router.post('/:id/pages', ...teacher, c.addPage);
|
||||
router.put('/:id/page', ...teacher, c.changePage);
|
||||
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
|
||||
router.get('/:id/pages', ...auth, c.getPages);
|
||||
router.post('/:id/pages', ...teacher, c.addPage);
|
||||
router.put('/:id/page', ...teacher, c.changePage);
|
||||
router.patch('/:id/page-template', ...teacher, c.updatePageTemplate);
|
||||
router.patch('/:id/pages/:pageNum/name', ...teacher, c.renamePage);
|
||||
router.post('/:id/pages/:pageNum/duplicate', ...teacher, c.duplicatePage);
|
||||
router.delete('/:id/pages/:pageNum', ...teacher, c.deletePage);
|
||||
|
||||
// Hand raise
|
||||
router.post('/:id/hand', ...auth, c.raiseHand);
|
||||
@@ -82,6 +93,12 @@ router.post('/:id/mute', ...teacher, c.mutePeer);
|
||||
router.post('/:id/screen', ...teacher, c.screenStart);
|
||||
router.delete('/:id/screen', ...teacher, c.screenStop);
|
||||
|
||||
// Simulation: open/close/state/mode for all participants
|
||||
router.post('/:id/sim', ...teacher, c.simOpen);
|
||||
router.delete('/:id/sim', ...teacher, c.simClose);
|
||||
router.post('/:id/sim/state', ...teacher, c.simState);
|
||||
router.post('/:id/sim/mode', ...teacher, c.simMode);
|
||||
|
||||
// Cursor broadcast (all participants)
|
||||
router.post('/:id/cursor', ...auth, c.broadcastCursor);
|
||||
|
||||
@@ -95,10 +112,21 @@ router.delete('/:id/allow-draw/:userId', ...teacher, c.revokeDraw);
|
||||
// Session notes (per user)
|
||||
router.get('/:id/notes', ...auth, c.getNotes);
|
||||
router.put('/:id/notes', ...auth, c.saveNotes);
|
||||
router.get('/:id/notes/all', ...teacher, c.getAllNotes);
|
||||
|
||||
// Session summary & history detail
|
||||
router.get('/:id/summary', ...auth, c.getSessionSummary);
|
||||
router.get('/:id/chat/export', ...teacher, c.exportChat);
|
||||
router.delete('/:id/history', ...teacher, c.deleteHistorySession);
|
||||
|
||||
// Save current session as template
|
||||
router.post('/:id/save-template', ...teacher, c.saveTemplate);
|
||||
// Load template into current session
|
||||
router.post('/:id/load-template', ...teacher, c.loadTemplate);
|
||||
|
||||
// Guest token (generate/revoke/get)
|
||||
router.get('/:id/guest-token', ...teacher, c.getGuestToken);
|
||||
router.post('/:id/guest-token', ...teacher, c.generateGuestToken);
|
||||
router.delete('/:id/guest-token', ...teacher, c.revokeGuestToken);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Public (no-auth) guest classroom routes.
|
||||
* Guests access the whiteboard read-only via a token in the URL.
|
||||
*
|
||||
* GET /api/classroom/guest/:token — session info (pre-join)
|
||||
* POST /api/classroom/guest/:token/join — choose display name, get guestId
|
||||
* GET /api/classroom/guest/:token/strokes — strokes for ?page_num=N
|
||||
* GET /api/classroom/guest/:token/stream — SSE stream (?guestId=X)
|
||||
* POST /api/classroom/guest/:token/leave — notify departure (optional)
|
||||
*/
|
||||
|
||||
const router = require('express').Router();
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db/db');
|
||||
const { addGuestClient, removeGuestClient, emitToGuests, emit } = require('../sse');
|
||||
|
||||
/* ── In-memory guest registry (cleared on server restart — fine for guests) */
|
||||
const guests = new Map(); // guestId → { name, sessionId, connectedAt }
|
||||
|
||||
/* Helper: look up session by token (active only) */
|
||||
function sessionByToken(token) {
|
||||
return db.prepare(
|
||||
"SELECT id, title, status, current_page, teacher_id FROM classroom_sessions WHERE guest_token=?"
|
||||
).get(token);
|
||||
}
|
||||
|
||||
/* Helper: emit guest-joined/left to the real participants of the session */
|
||||
function notifySession(sessionId, data) {
|
||||
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
|
||||
if (!session) return;
|
||||
const { emit: _emit, emitToClass } = require('../sse');
|
||||
if (session.class_id) {
|
||||
emitToClass(session.class_id, data);
|
||||
_emit(session.teacher_id, data);
|
||||
} else {
|
||||
_emit(session.teacher_id, data);
|
||||
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
|
||||
for (const { user_id } of invites) _emit(user_id, data);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GET /api/classroom/guest/:token ─── session info (pre-join screen) */
|
||||
router.get('/:token', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
|
||||
const pageCount = db.prepare(
|
||||
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
||||
).get(session.id).c || 1;
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
title: session.title || 'Онлайн-урок',
|
||||
status: session.status,
|
||||
current_page: session.current_page || 1,
|
||||
page_count: pageCount,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── POST /api/classroom/guest/:token/join ─── choose name, get guestId */
|
||||
router.post('/:token/join', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
if (session.status !== 'active')
|
||||
return res.status(403).json({ error: 'Урок ещё не начался или уже завершён' });
|
||||
|
||||
const rawName = (req.body?.name || '').trim().slice(0, 40);
|
||||
const name = rawName || 'Гость';
|
||||
|
||||
const guestId = 'g_' + crypto.randomBytes(12).toString('base64url');
|
||||
guests.set(guestId, { name, sessionId: session.id, connectedAt: Date.now() });
|
||||
|
||||
// Notify real participants that a guest joined
|
||||
notifySession(session.id, {
|
||||
type: 'classroom_guest_joined',
|
||||
sessionId: session.id,
|
||||
guestId,
|
||||
guestName: name,
|
||||
});
|
||||
|
||||
const pageCount = db.prepare(
|
||||
'SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?'
|
||||
).get(session.id).c || 1;
|
||||
|
||||
res.json({
|
||||
guestId,
|
||||
sessionId: session.id,
|
||||
title: session.title || 'Онлайн-урок',
|
||||
current_page: session.current_page || 1,
|
||||
page_count: pageCount,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── GET /api/classroom/guest/:token/strokes ─── whiteboard strokes */
|
||||
router.get('/:token/strokes', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).json({ error: 'Ссылка недействительна' });
|
||||
if (session.status !== 'active')
|
||||
return res.status(403).json({ error: 'Урок не активен' });
|
||||
|
||||
const pageNum = Math.max(1, Number(req.query.page_num) || 1);
|
||||
const sinceSeq = Number(req.query.since_seq) || 0;
|
||||
|
||||
const strokes = sinceSeq > 0
|
||||
? db.prepare(
|
||||
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq'
|
||||
).all(session.id, pageNum, sinceSeq)
|
||||
: db.prepare(
|
||||
'SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
|
||||
).all(session.id, pageNum);
|
||||
|
||||
const pageRow = db.prepare(
|
||||
'SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?'
|
||||
).get(session.id, pageNum);
|
||||
|
||||
const parsed = strokes.map(s => ({ ...s, data: JSON.parse(s.data || '{}') }));
|
||||
res.json({ strokes: parsed, template: pageRow?.template || 'blank', seq: parsed.at(-1)?.seq || 0 });
|
||||
});
|
||||
|
||||
/* ── GET /api/classroom/guest/:token/stream ─── SSE */
|
||||
router.get('/:token/stream', (req, res) => {
|
||||
const session = sessionByToken(req.params.token);
|
||||
if (!session) return res.status(404).end();
|
||||
if (session.status !== 'active') return res.status(403).end();
|
||||
|
||||
const guestId = req.query.guestId;
|
||||
const guest = guestId ? guests.get(guestId) : null;
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable buffering
|
||||
res.flushHeaders();
|
||||
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
|
||||
|
||||
addGuestClient(session.id, res);
|
||||
|
||||
req.on('close', () => {
|
||||
removeGuestClient(session.id, res);
|
||||
if (guest) {
|
||||
guests.delete(guestId);
|
||||
notifySession(session.id, {
|
||||
type: 'classroom_guest_left',
|
||||
sessionId: session.id,
|
||||
guestId,
|
||||
guestName: guest.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ── POST /api/classroom/guest/:token/leave ─── explicit goodbye */
|
||||
router.post('/:token/leave', (req, res) => {
|
||||
const guestId = req.body?.guestId;
|
||||
const guest = guestId ? guests.get(guestId) : null;
|
||||
if (guest) {
|
||||
guests.delete(guestId);
|
||||
notifySession(guest.sessionId, {
|
||||
type: 'classroom_guest_left',
|
||||
sessionId: guest.sessionId,
|
||||
guestId,
|
||||
guestName: guest.name,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user