From 068d6c2afefcd497a4036829921d076d0491340a Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 11:41:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(classroom):=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B5=20=D0=BB=D1=8E=D0=B1=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=83=D1=87=D0=B5=D0=B1=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D0=BD=D0=BB=D0=B0=D0=B9=D0=BD-=D1=83=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учитель может выбрать любой активный учебник из каталога /api/textbooks и открыть его в общем iframe для всех участников. По аналогии с симуляциями: - Backend: контроллер classroom/textbook.js + 4 роута (POST/DELETE /:id/textbook, /:id/textbook/nav, /:id/textbook/mode) с SSE-событиями classroom_textbook_open|close|nav|mode - Embed-режим /textbook/:slug?embed=1: сервер injectит CSS+JS-bridge перед , скрывая хедер/сайдбар и пересылая клики/скролл наверх через postMessage (без правки 40+ HTML-учебников) - Frontend (classroom.html): кнопка «Учебник» в header, пикер с фильтрами по предмету, iframe-панель с режимами демо/свободно, relay nav-событий учителя → всем студентам в demo-режиме --- backend/src/controllers/classroom/textbook.js | 80 +++++ .../src/controllers/classroomController.js | 1 + backend/src/routes/classroom.js | 6 + backend/src/server.js | 93 ++++++ frontend/classroom.html | 302 ++++++++++++++++++ 5 files changed, 482 insertions(+) create mode 100644 backend/src/controllers/classroom/textbook.js diff --git a/backend/src/controllers/classroom/textbook.js b/backend/src/controllers/classroom/textbook.js new file mode 100644 index 0000000..7889223 --- /dev/null +++ b/backend/src/controllers/classroom/textbook.js @@ -0,0 +1,80 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToSession } = require('./_shared'); + +const _stmtTextbookBySlug = db.prepare( + 'SELECT slug, title, subject, grade FROM textbooks WHERE slug=? AND is_active=1' +); + +function _requireTeacher(req, res) { + const sessionId = Number(req.params.id); + const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); + if (!session) { res.status(404).json({ error: 'Сессия не активна' }); return null; } + if (session.teacher_id !== req.user.id && req.user.role !== 'admin') { + res.status(403).json({ error: 'Нет доступа' }); return null; + } + return sessionId; +} + +function textbookOpen(req, res) { + const sessionId = _requireTeacher(req, res); + if (!sessionId) return; + + const { slug, hash } = req.body || {}; + if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug)) + return res.status(400).json({ error: 'Неверный slug' }); + + const tb = _stmtTextbookBySlug.get(slug); + if (!tb) return res.status(404).json({ error: 'Учебник не найден' }); + + const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null; + + emitToSession(sessionId, { + type: 'classroom_textbook_open', + sessionId, + slug: tb.slug, + title: tb.title, + subject: tb.subject, + grade: tb.grade, + hash: safeHash, + }); + res.json({ ok: true }); +} + +function textbookNav(req, res) { + const sessionId = _requireTeacher(req, res); + if (!sessionId) return; + + const { slug, hash, scrollY } = req.body || {}; + if (!slug || typeof slug !== 'string' || !/^[a-z0-9_-]{1,80}$/i.test(slug)) + return res.status(400).json({ error: 'Неверный slug' }); + if (!_stmtTextbookBySlug.get(slug)) + return res.status(404).json({ error: 'Учебник не найден' }); + + const safeHash = (typeof hash === 'string' && /^[a-z0-9_-]{1,40}$/i.test(hash)) ? hash : null; + const sy = (typeof scrollY === 'number' && isFinite(scrollY)) ? Math.max(0, Math.min(scrollY, 1e6)) : null; + + emitToSession(sessionId, { + type: 'classroom_textbook_nav', + sessionId, slug, hash: safeHash, scrollY: sy, + }); + res.json({ ok: true }); +} + +function textbookMode(req, res) { + const sessionId = _requireTeacher(req, res); + if (!sessionId) return; + const { mode } = req.body || {}; + if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' }); + emitToSession(sessionId, { type: 'classroom_textbook_mode', sessionId, mode }); + res.json({ ok: true }); +} + +function textbookClose(req, res) { + const sessionId = _requireTeacher(req, res); + if (!sessionId) return; + emitToSession(sessionId, { type: 'classroom_textbook_close', sessionId }); + res.json({ ok: true }); +} + +module.exports = { textbookOpen, textbookNav, textbookMode, textbookClose }; diff --git a/backend/src/controllers/classroomController.js b/backend/src/controllers/classroomController.js index ee9ca0d..6c931e0 100644 --- a/backend/src/controllers/classroomController.js +++ b/backend/src/controllers/classroomController.js @@ -8,5 +8,6 @@ module.exports = { ...require('./classroom/chat'), ...require('./classroom/permissions'), ...require('./classroom/sim'), + ...require('./classroom/textbook'), ...require('./classroom/admin'), }; diff --git a/backend/src/routes/classroom.js b/backend/src/routes/classroom.js index 373df60..c8a5ce5 100644 --- a/backend/src/routes/classroom.js +++ b/backend/src/routes/classroom.js @@ -108,6 +108,12 @@ router.post('/:id/sim/state', ...teacher, c.simState); router.post('/:id/sim/mode', ...teacher, c.simMode); router.post('/:id/sim/annotate', ...teacher, c.simAnnotate); +// Textbook: open/close/navigate for all participants +router.post('/:id/textbook', ...teacher, c.textbookOpen); +router.delete('/:id/textbook', ...teacher, c.textbookClose); +router.post('/:id/textbook/nav', ...teacher, c.textbookNav); +router.post('/:id/textbook/mode', ...teacher, c.textbookMode); + // Cursor broadcast (all participants) router.post('/:id/cursor', ...auth, cursorLimiter, c.broadcastCursor); diff --git a/backend/src/server.js b/backend/src/server.js index e051969..e192811 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -326,13 +326,106 @@ app.use((req, res, next) => { }); // Clean URL for textbooks: /textbook/ → frontend/textbooks/ +// With ?embed=1 — inject CSS that hides headers/sidebars + JS-bridge for classroom sync. +const fs = require('fs'); const _textbookDb = require('./db/db'); const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1'); +const _embedCache = new Map(); // html_path → {mtime, html} + +const EMBED_INJECT = ` + + +`; + +function _renderEmbed(filePath, slug) { + let stat; try { stat = fs.statSync(filePath); } catch { return null; } + const cached = _embedCache.get(filePath); + if (cached && cached.mtime === stat.mtimeMs && cached.slug === slug) return cached.html; + let html; + try { html = fs.readFileSync(filePath, 'utf8'); } catch { return null; } + const inject = EMBED_INJECT.replace('__LS_SLUG__', JSON.stringify(slug)); + if (html.includes('')) html = html.replace('', inject + ''); + else html = inject + html; + _embedCache.set(filePath, { mtime: stat.mtimeMs, slug, html }); + return html; +} + app.get('/textbook/:slug', (req, res, next) => { const row = _stmtTextbookPath.get(req.params.slug); if (!row) return next(); const filePath = path.join(frontendDir, 'textbooks', row.html_path); if (!isProd) res.setHeader('Cache-Control', 'no-store'); + if (req.query.embed === '1') { + const html = _renderEmbed(filePath, req.params.slug); + if (html == null) return next(); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.send(html); + } res.sendFile(filePath, err => { if (err) next(); }); }); diff --git a/frontend/classroom.html b/frontend/classroom.html index 59d4567..a8dcc18 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -2118,6 +2118,52 @@ .cr-sim-picker-card-cat.chem { background: rgba(241,91,181,0.1); border-color: rgba(241,91,181,0.3); color: #F15BB5; } .cr-sim-picker-card-cat.bio { background: rgba(168,224,99,0.1); border-color: rgba(168,224,99,0.3); color: #A8E063; } .cr-sim-picker-card-cat.game { background: rgba(255,159,67,0.1); border-color: rgba(255,159,67,0.3); color: #FF9F43; } + + /* ── Textbook panel (mirrors sim panel) ─────────────────────────────── */ + .cr-tb-panel { + position: absolute; inset: 0; z-index: 5; + background: #fff; display: none; flex-direction: column; + border-left: 1px solid rgba(155,93,229,0.2); + } + .cr-tb-panel.open { display: flex; } + .cr-tb-bar { + display: flex; align-items: center; gap: 10px; + padding: 8px 14px; background: #1a0f2e; flex-shrink: 0; + border-bottom: 1px solid rgba(155,93,229,0.2); + } + .cr-tb-bar-icon { + width: 24px; height: 24px; border-radius: 7px; + background: rgba(6,214,224,0.15); border: 1px solid rgba(6,214,224,0.3); + display: flex; align-items: center; justify-content: center; + } + .cr-tb-bar-icon svg { width: 12px; height: 12px; stroke: #06D6E0; } + .cr-tb-bar-title { font-size: 0.82rem; font-weight: 700; color: #f0e8ff; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .cr-tb-bar-close { + width: 26px; height: 26px; border-radius: 7px; border: none; + background: rgba(241,91,181,0.1); color: rgba(255,255,255,0.7); + cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s; + } + .cr-tb-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; } + .cr-tb-bar-close svg { width: 14px; height: 14px; } + .cr-tb-mode { + display: flex; gap: 4px; padding: 3px; background: rgba(255,255,255,0.04); + border-radius: 99px; border: 1px solid rgba(255,255,255,0.06); + } + .cr-tb-mode-btn { + padding: 4px 12px; border-radius: 99px; border: 1.5px solid transparent; + background: transparent; color: rgba(255,255,255,0.5); + font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700; cursor: pointer; transition: all .15s; + } + .cr-tb-mode-btn:hover { color: rgba(255,255,255,0.7); } + .cr-tb-mode-btn.active { background: rgba(6,214,224,0.18); border-color: rgba(6,214,224,0.45); color: #06D6E0; } + .cr-tb-frame { + flex: 1; width: 100%; border: 0; background: #fff; + } + .cr-tb-blocker { + position: absolute; inset: 38px 0 0 0; + display: none; background: transparent; cursor: not-allowed; z-index: 10; + } + .cr-tb-blocker.active { display: block; } @@ -2149,6 +2195,10 @@ Симуляция + + + + + + +
+
@@ -3678,6 +3746,14 @@ if (_sessionId == data.sessionId) onSimModeChange(data.mode); } else if (data.type === 'classroom_sim_annotate') { if (_sessionId == data.sessionId) _crApplyAnnotate(data.active); + } else if (data.type === 'classroom_textbook_open') { + if (_sessionId == data.sessionId) onTbOpen(data); + } else if (data.type === 'classroom_textbook_close') { + if (_sessionId == data.sessionId) onTbClose(); + } else if (data.type === 'classroom_textbook_nav') { + if (_sessionId == data.sessionId) onTbNav(data); + } else if (data.type === 'classroom_textbook_mode') { + if (_sessionId == data.sessionId) onTbModeChange(data.mode); } else if (data.type === '_sse_reconnect') { // SSE reconnected after a drop — re-sync all real-time state to fill the gap if (_sessionId) resyncAfterReconnect(); @@ -3980,6 +4056,7 @@ document.getElementById('cr-leave-btn').style.display = isTeacher ? 'none' : 'flex'; document.getElementById('cr-screen-btn').style.display = isTeacher ? 'flex' : 'none'; document.getElementById('cr-sim-btn').style.display = isTeacher ? 'flex' : 'none'; + document.getElementById('cr-tb-btn').style.display = isTeacher ? 'flex' : 'none'; document.getElementById('cr-guest-btn').style.display = isTeacher ? 'flex' : 'none'; document.getElementById('cr-share-lib-btn').style.display = isTeacher ? 'flex' : 'none'; document.getElementById('cr-hand-btn').style.display = isTeacher ? 'none' : 'flex'; @@ -7099,6 +7176,211 @@ }, 300); }); + /* ════════════════════════════════════════════════════════════════════ + TEXTBOOK in classroom — open any textbook in shared iframe + ════════════════════════════════════════════════════════════════════ */ + let _tbActive = null; // current slug, or null + let _tbMode = 'demo'; // 'demo' | 'free' + let _tbList = null; // cached catalog from GET /api/textbooks + let _tbPickerSubj = 'all'; + let _tbNavThrottle = null; + + async function crOpenTbPicker() { + if (_tbActive) { crTeacherCloseTb(); return; } + const overlay = document.getElementById('cr-tb-picker-overlay'); + overlay.classList.add('open'); + if (!_tbList) { + try { + const data = await LS.get('/api/textbooks'); + _tbList = Array.isArray(data) ? data : (data?.textbooks || data?.items || []); + } catch (e) { + document.getElementById('cr-tb-picker-grid').innerHTML = + `
Не удалось загрузить список учебников
`; + return; + } + _crRenderTbCats(); + } + _crRenderTbGrid(_tbPickerSubj); + } + + function crCloseTbPicker() { + document.getElementById('cr-tb-picker-overlay').classList.remove('open'); + } + + function crFilterTbs(subj, btn) { + _tbPickerSubj = subj; + document.querySelectorAll('#cr-tb-picker-cats .cr-sim-cat-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _crRenderTbGrid(subj); + } + + function _crRenderTbCats() { + const subjs = [...new Set((_tbList || []).map(t => t.subject).filter(Boolean))].sort(); + const SUBJ_LABEL = { + math: 'Математика', algebra: 'Алгебра', geometry: 'Геометрия', + physics: 'Физика', chemistry: 'Химия', biology: 'Биология', + informatics: 'Информатика', russian: 'Русский', english: 'Английский', + }; + const cats = document.getElementById('cr-tb-picker-cats'); + cats.innerHTML = `` + + subjs.map(s => ``).join(''); + } + + function _crRenderTbGrid(subj) { + const grid = document.getElementById('cr-tb-picker-grid'); + const list = (_tbList || []).filter(t => subj === 'all' || t.subject === subj); + if (!list.length) { + grid.innerHTML = `
Нет учебников
`; + return; + } + grid.innerHTML = list.map(t => { + const slugSafe = String(t.slug).replace(/[^a-z0-9_-]/gi, ''); + const titleEsc = String(t.title || t.slug).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const grade = t.grade ? `${t.grade} кл.` : ''; + return ` +
+ ${grade || (t.subject || '')} + ${titleEsc} +
`; + }).join(''); + } + + async function crPickTb(slug) { + crCloseTbPicker(); + if (!_sessionId) return; + try { + await LS.post(`/api/classroom/${_sessionId}/textbook`, { slug }); + // onTbOpen will fire via SSE echo + } catch (e) { + LS.toast(e.message || 'Ошибка открытия учебника', 'error'); + } + } + + async function crTeacherCloseTb() { + if (!_sessionId) return; + try { await LS.del(`/api/classroom/${_sessionId}/textbook`); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + + async function crSetTbMode(mode) { + if (!_sessionId) return; + try { await LS.post(`/api/classroom/${_sessionId}/textbook/mode`, { mode }); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } + + function onTbOpen(data) { + _tbActive = data.slug; + const panel = document.getElementById('cr-tb-panel'); + const frame = document.getElementById('cr-tb-frame'); + const titleEl = document.getElementById('cr-tb-bar-title'); + const closeBtn = document.getElementById('cr-tb-bar-close'); + const modeTog = document.getElementById('cr-tb-mode-toggle'); + const headerBtn = document.getElementById('cr-tb-btn'); + const blocker = document.getElementById('cr-tb-blocker'); + const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin'); + + titleEl.textContent = data.title || data.slug; + let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`; + if (data.hash) src += '#' + data.hash; + frame.src = src; + panel.classList.add('open'); + + if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none'; + if (modeTog) modeTog.style.display = isTeacher ? 'flex' : 'none'; + if (headerBtn && isTeacher) { + headerBtn.title = 'Закрыть учебник'; + headerBtn.querySelector('span').textContent = 'Закрыть'; + } + if (blocker) blocker.classList.toggle('active', !isTeacher && _tbMode === 'demo'); + onTbModeChange(_tbMode); + } + + function onTbClose() { + _tbActive = null; + const panel = document.getElementById('cr-tb-panel'); + const frame = document.getElementById('cr-tb-frame'); + const headerBtn = document.getElementById('cr-tb-btn'); + const modeTog = document.getElementById('cr-tb-mode-toggle'); + const blocker = document.getElementById('cr-tb-blocker'); + panel.classList.remove('open'); + if (modeTog) modeTog.style.display = 'none'; + if (blocker) blocker.classList.remove('active'); + setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300); + if (headerBtn) { + headerBtn.title = 'Открыть учебник'; + const sp = headerBtn.querySelector('span'); if (sp) sp.textContent = 'Учебник'; + } + } + + function onTbNav(data) { + if (!_tbActive) return; + const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin'); + // In demo mode студенты следуют за учителем. В free — игнорируем nav-события. + if (isTeacher) return; + if (_tbMode !== 'demo') return; + const frame = document.getElementById('cr-tb-frame'); + if (!frame) return; + // Если slug сменился — перезагружаем iframe, иначе шлём postMessage + if (data.slug && data.slug !== _tbActive) { + _tbActive = data.slug; + document.getElementById('cr-tb-bar-title').textContent = data.title || data.slug; + let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`; + if (data.hash) src += '#' + data.hash; + frame.src = src; + } else { + frame.contentWindow?.postMessage( + { type: 'ls_tb_apply', hash: data.hash, scrollY: data.scrollY }, '*' + ); + } + } + + function onTbModeChange(mode) { + _tbMode = mode; + const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin'); + const blocker = document.getElementById('cr-tb-blocker'); + const frame = document.getElementById('cr-tb-frame'); + if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo'); + document.getElementById('cr-tb-mode-demo')?.classList.toggle('active', mode === 'demo'); + document.getElementById('cr-tb-mode-free')?.classList.toggle('active', mode === 'free'); + // Сообщаем iframe — блокировать клики у студента в demo + if (frame && !isTeacher) { + frame.contentWindow?.postMessage({ type: 'ls_tb_lock', locked: mode === 'demo' }, '*'); + } + } + + // Teacher: relay textbook nav (hash/scroll) from iframe → backend → SSE + window.addEventListener('message', e => { + if (!_tbActive || !_sessionId) return; + const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin'; + if (!isTeacher) return; + const d = e.data; + if (!d || typeof d !== 'object') return; + if (d.type === 'ls_tb_nav') { + // navigation: hash или ссылка на другой учебник — отправляем сразу + const slug = d.slug || _tbActive; + LS.post(`/api/classroom/${_sessionId}/textbook/nav`, { + slug, hash: d.hash || null + }).catch(() => {}); + // если открыли другой учебник — учитель сам переключает iframe (студенты получат через SSE) + if (slug !== _tbActive) { + const frame = document.getElementById('cr-tb-frame'); + if (frame) { + let src = `/textbook/${encodeURIComponent(slug)}?embed=1`; + if (d.hash) src += '#' + d.hash; + frame.src = src; + _tbActive = slug; + } + } + } else if (d.type === 'ls_tb_scroll') { + clearTimeout(_tbNavThrottle); + _tbNavThrottle = setTimeout(() => { + LS.post(`/api/classroom/${_sessionId}/textbook/nav`, { + slug: _tbActive, scrollY: d.scrollY + }).catch(() => {}); + }, 350); + } + }); + function crToggleDrawPermission(uid) { if (!_sessionId) return; const numUid = Number(uid); @@ -7841,6 +8123,26 @@
+ +
+
+
+

Выбрать учебник

+ +
+
+ +
+
+
+
Загрузка…
+
+
+
+
+