feat(classroom): открытие любого учебника в онлайн-уроке

Учитель может выбрать любой активный учебник из каталога /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
  перед </head>, скрывая хедер/сайдбар и пересылая клики/скролл наверх
  через postMessage (без правки 40+ HTML-учебников)
- Frontend (classroom.html): кнопка «Учебник» в header, пикер с
  фильтрами по предмету, iframe-панель с режимами демо/свободно,
  relay nav-событий учителя → всем студентам в demo-режиме
This commit is contained in:
Maxim Dolgolyov
2026-05-29 11:41:57 +03:00
parent 21c5ae2d91
commit 068d6c2afe
5 changed files with 482 additions and 0 deletions
+93
View File
@@ -326,13 +326,106 @@ app.use((req, res, next) => {
});
// Clean URL for textbooks: /textbook/<slug> → frontend/textbooks/<html_path>
// 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 = `
<style id="__ls_embed_style__">
/* Скрываем хедеры/боковые навигации в embed-режиме classroom */
.hdr, .hdr-back, .hdr-side, .app-layout > .sidebar, .sidebar, .mob-bar,
.topbar, .topbar-nav, .tb-back, .ls-topbar, .ls-sidebar, .sb-content > .sidebar,
header.hdr, nav.sidebar { display: none !important; }
.app-layout { display: block !important; }
.sb-content { margin-left: 0 !important; width: 100% !important; }
body { padding-top: 0 !important; margin-top: 0 !important; }
main { padding-top: 16px !important; }
html, body { background: #fff; }
</style>
<script id="__ls_embed_bridge__">
(function(){
try {
if (window.parent === window) return;
var SLUG = __LS_SLUG__;
// Перехват внутренних ссылок → передача наверх (учитель транслирует всем)
document.addEventListener('click', function(e){
var a = e.target.closest && e.target.closest('a[href]');
if (!a) return;
var href = a.getAttribute('href') || '';
if (!href || href.startsWith('javascript:')) return;
if (href.startsWith('#')) {
// hash-навигация внутри страницы
window.parent.postMessage({ type:'ls_tb_nav', slug:SLUG, hash:href.slice(1) }, '*');
return;
}
// ссылка на другой учебник (/textbook/<slug>)
var m = href.match(/^\\/textbook\\/([a-z0-9_-]+)/i);
if (m) {
e.preventDefault();
window.parent.postMessage({ type:'ls_tb_nav', slug:m[1], hash:null }, '*');
}
}, true);
// Скролл (throttled) — учитель транслирует
var st;
window.addEventListener('scroll', function(){
clearTimeout(st);
st = setTimeout(function(){
window.parent.postMessage({ type:'ls_tb_scroll', slug:SLUG, scrollY:window.scrollY }, '*');
}, 250);
}, { passive:true });
// Принимаем команды от parent (для студентов в demo-режиме)
window.addEventListener('message', function(e){
var d = e.data; if (!d || typeof d !== 'object') return;
if (d.type === 'ls_tb_apply') {
if (d.hash != null) { try { location.hash = '#' + d.hash; } catch(_){} }
if (typeof d.scrollY === 'number') { try { window.scrollTo(0, d.scrollY); } catch(_){} }
}
if (d.type === 'ls_tb_lock') {
// блокируем взаимодействие у студентов в demo
var el = document.getElementById('__ls_embed_lock__');
if (d.locked && !el) {
el = document.createElement('div');
el.id = '__ls_embed_lock__';
el.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:transparent;cursor:not-allowed';
document.body.appendChild(el);
} else if (!d.locked && el) {
el.remove();
}
}
});
// Сообщаем parent что embed готов
window.parent.postMessage({ type:'ls_tb_ready', slug:SLUG }, '*');
} catch(_){}
})();
</script>
`;
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('</head>')) html = html.replace('</head>', inject + '</head>');
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(); });
});