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:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+25 -1
View File
@@ -154,7 +154,31 @@
"Bash(powershell -Command \"Stop-Process -Id 69696 -Force\")",
"Bash(powershell -Command \"Start-Sleep 1\")",
"Bash(powershell -Command \"\\(Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue\\).OwningProcess\")",
"Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")"
"Bash(powershell -Command \"Stop-Process -Id 10880 -Force\")",
"Bash(grep -v '\\\\.js$')",
"Bash(curl -s -X POST http://localhost:3000/api/auth/login -H \"Content-Type: application/json\" -d '{\"login\":\"admin\",\"password\":\"admin123\"}')",
"Bash(curl -s -w '\\\\nHTTP_STATUS:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' http://localhost:3000/api/classroom/6/pages)",
"Bash(curl -s -w '\\\\nHTTP:%{http_code}' -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwidmVyc2lvbiI6MCwiaWF0IjoxNzc1OTgyNzc2LCJleHAiOjE3NzYwNjkxNzZ9.FJ3Ya9X_Qg5fEUagPc1l8KrDnj2BaKrXarA-KRVr_QM' 'http://localhost:3000/api/classroom/6/strokes?page_num=1')",
"Bash(wmic process:*)",
"Bash(taskkill /F /PID 67276)",
"Bash(cmd /c \"taskkill /F /PID 67276\")",
"Bash(cmd /c \"taskkill /F /PID 67276 && echo killed\")",
"Bash(cmd /c \"wmic process where ProcessId=67276 delete\")",
"Bash(powershell -Command \"Stop-Process -Id 67276 -Force\")",
"Bash(powershell -Command \"Start-Sleep -Milliseconds 1500; \\(Invoke-WebRequest -Uri 'http://localhost:3000/api/health' -UseBasicParsing\\).Content\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/lesson-history)",
"Bash(curl -s http://localhost:3000/api/classroom/my/history -H \"Authorization: Bearer test\")",
"Bash(curl -s -X DELETE http://localhost:3000/api/classroom/999/history -H \"Authorization: Bearer bad\")",
"Bash(pkill -f \"node.*server\")",
"Bash(grep -n \"</div>.*app-layout\\\\|<!-- app-layout\\\\|^</div>$\" frontend/classroom.html)",
"Bash(taskkill /PID 1336 /F)",
"Bash(taskkill /PID 55392 /F)",
"Bash(taskkill /PID 60564 /F)",
"Bash(ping -n 3 127.0.0.1)",
"Bash(cmd /c \"taskkill /PID 60564 /F\")",
"Bash(cmd /c \"taskkill /F /PID 60564 2>&1\")",
"Bash(kill -9 60564)",
"Bash(kill -9 9313)"
],
"additionalDirectories": [
"\\tmp"
+23 -1
View File
@@ -15,7 +15,8 @@
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"ws": "^8.20.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
@@ -2038,6 +2039,27 @@
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+3 -2
View File
@@ -12,13 +12,14 @@
},
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.8.1",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"sharp": "^0.34.5"
"sharp": "^0.34.5",
"ws": "^8.20.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
+1 -1
View File
@@ -324,7 +324,7 @@ function getFeatures(_req, res) {
/* ── PATCH /api/admin/features ──────────────────────────────────────── */
function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz'];
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom'];
const updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const changed = [];
+568 -3
View File
@@ -1,7 +1,8 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { emit, emitToClass, getOnlineUserIds } = require('../sse');
const crypto = require('crypto');
const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse');
/* ── chat attachment uploads dir ─────────────────────────────────────── */
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
@@ -15,6 +16,14 @@ function canDraw(sessionId, userId, session) {
).get(sessionId, userId);
}
/* Events forwarded to read-only guest viewers (whiteboard + lifecycle only) */
const GUEST_EVENTS = new Set([
'classroom_strokes', 'classroom_stroke_preview', 'classroom_stroke_deleted',
'classroom_stroke_updated', 'classroom_page_added', 'classroom_page_changed',
'classroom_template_changed', 'classroom_page_cleared', 'classroom_page_renamed',
'classroom_page_duplicated', 'classroom_page_deleted', 'classroom_ended',
]);
/* ── Helper: broadcast to all session participants ─────────────────────── */
function emitToSession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
@@ -29,6 +38,9 @@ function emitToSession(sessionId, 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);
}
// Forward whitelisted events to guest viewers
if (GUEST_EVENTS.has(data.type)) emitToGuests(sessionId, data);
}
/* ── Helper: check if user has access to session ──────────────────────── */
@@ -50,6 +62,12 @@ function createSession(req, res) {
const { class_id, user_ids, title = '' } = req.body;
const teacher = req.user;
// Check if classroom module is enabled
const classroomEnabled = db.prepare("SELECT value FROM app_settings WHERE key='feature_classroom_enabled'").get();
if (classroomEnabled?.value === '0') {
return res.status(403).json({ error: 'Модуль онлайн-уроков отключён администратором' });
}
if (!class_id && (!user_ids || !user_ids.length)) {
return res.status(400).json({ error: 'Укажите class_id или user_ids' });
}
@@ -585,10 +603,11 @@ function getStrokes(req, res) {
const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
const pageRow = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const pageRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const template = pageRow?.template || 'blank';
const name = pageRow?.name || null;
res.json({ strokes, template });
res.json({ strokes, template, name });
}
/* PATCH /api/classroom/:id/strokes/:strokeId — update image position/size */
@@ -656,6 +675,103 @@ function clearPage(req, res) {
res.json({ ok: true });
}
/* GET /api/classroom/:id/pages — list all pages with names/templates */
function getPages(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
const total = Math.max(session.current_page, maxS, maxP, 1);
const rows = db.prepare('SELECT page_num, template, name FROM classroom_pages WHERE session_id=?').all(sessionId);
const map = {};
rows.forEach(r => { map[r.page_num] = r; });
const pages = [];
for (let i = 1; i <= total; i++) {
pages.push({ page_num: i, template: map[i]?.template || 'blank', name: map[i]?.name || null });
}
res.json({ pages, currentPage: session.current_page });
}
/* PATCH /api/classroom/:id/pages/:pageNum/name — rename page */
function renamePage(req, res) {
const sessionId = Number(req.params.id);
const pageNum = Number(req.params.pageNum);
const { name } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, pageNum, 'blank');
db.prepare('UPDATE classroom_pages SET name=? WHERE session_id=? AND page_num=?').run(name || null, sessionId, pageNum);
emitToSession(sessionId, { type: 'classroom_page_renamed', sessionId, pageNum, name: name || null });
res.json({ ok: true, pageNum, name: name || null });
}
/* POST /api/classroom/:id/pages/:pageNum/duplicate — duplicate a page */
function duplicatePage(req, res) {
const sessionId = Number(req.params.id);
const srcPage = Number(req.params.pageNum);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const srcRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, srcPage);
const srcTpl = srcRow?.template || 'blank';
const newName = srcRow?.name ? srcRow.name + ' (копия)' : null;
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
const newPage = Math.max(session.current_page, maxS, maxP) + 1;
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template, name) VALUES (?,?,?,?)').run(sessionId, newPage, srcTpl, newName);
const strokes = db.prepare('SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage);
const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data) VALUES (?,?,?,?)');
db.transaction(() => { strokes.forEach(s => ins.run(sessionId, newPage, s.tool, s.data)); })();
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId);
emitToSession(sessionId, { type: 'classroom_page_duplicated', sessionId, srcPage, newPage, template: srcTpl, name: newName });
res.json({ ok: true, newPage, template: srcTpl, name: newName });
}
/* DELETE /api/classroom/:id/pages/:pageNum — delete a page */
function deletePage(req, res) {
const sessionId = Number(req.params.id);
const pageNum = Number(req.params.pageNum);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m;
const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m;
const total = Math.max(session.current_page, maxS, maxP, 1);
if (total <= 1) return res.status(400).json({ error: 'Нельзя удалить единственную страницу' });
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum);
db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum);
db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum);
db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum);
let newCurrent = session.current_page;
if (newCurrent > pageNum) newCurrent--;
else if (newCurrent === pageNum) newCurrent = Math.max(1, pageNum - 1);
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newCurrent, sessionId);
emitToSession(sessionId, { type: 'classroom_page_deleted', sessionId, pageNum, newCurrent });
res.json({ ok: true, pageNum, newCurrent });
}
/* POST /api/classroom/:id/mute — teacher mutes a student */
function mutePeer(req, res) {
const sessionId = Number(req.params.id);
@@ -797,6 +913,66 @@ function screenStop(req, res) {
res.json({ ok: true });
}
/* ── Simulation integration ──────────────────────────────────────────────── */
/* POST /api/classroom/:id/sim — teacher opens simulation for everyone */
function simOpen(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) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const { simId, title } = req.body;
if (!simId || typeof simId !== 'string' || !/^[a-z0-9_-]{1,40}$/.test(simId))
return res.status(400).json({ error: 'Неверный simId' });
emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (title || simId).slice(0, 80) });
res.json({ ok: true });
}
/* POST /api/classroom/:id/sim/state — teacher relays sim state to students */
function simState(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) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const { state } = req.body;
if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Нет state' });
emitToSession(sessionId, { type: 'classroom_sim_state', sessionId, state });
res.json({ ok: true });
}
/* POST /api/classroom/:id/sim/mode — teacher sets sim mode (demo/free) */
function simMode(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) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const { mode } = req.body;
if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' });
emitToSession(sessionId, { type: 'classroom_sim_mode', sessionId, mode });
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/sim — teacher closes simulation */
function simClose(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) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, { type: 'classroom_sim_close', sessionId });
res.json({ ok: true });
}
/* ── Chat: upload image attachment ──────────────────────────────────────── */
function uploadChatAttachment(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
@@ -870,7 +1046,341 @@ function saveNotes(req, res) {
res.json({ ok: true });
}
/* ── Lesson history ─────────────────────────────────────────────────────── */
/* GET /api/classroom/class/:classId/history */
function getClassHistory(req, res) {
const classId = Number(req.params.classId);
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 20);
const search = (req.query.search || '').trim();
const offset = (page - 1) * limit;
// Access check: admin sees all; teacher must own the class; student must be member
const cls = db.prepare('SELECT * FROM classes WHERE id=?').get(classId);
if (!cls) return res.status(404).json({ error: 'Класс не найден' });
if (req.user.role === 'teacher') {
if (cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' });
} else if (req.user.role === 'student' || req.user.role === 'free_student') {
const member = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?').get(classId, req.user.id);
if (!member) return res.status(403).json({ error: 'Нет доступа' });
}
const whereSearch = search ? `AND (s.title LIKE '%'||?||'%')` : '';
const params = search ? [classId, search] : [classId];
const total = db.prepare(`
SELECT COUNT(*) AS n FROM classroom_sessions s
WHERE s.class_id=? AND s.status='ended' ${whereSearch}
`).get(...params).n;
const sessions = db.prepare(`
SELECT s.id, s.title, s.created_at, s.ended_at, s.teacher_id,
u.name AS teacher_name,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count,
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
WHERE s.class_id=? AND s.status='ended' ${whereSearch}
ORDER BY s.ended_at DESC
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
res.json({ sessions, total, page, pages: Math.ceil(total / limit) });
}
/* GET /api/classroom/my/history */
function getMyHistory(req, res) {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 20);
const offset = (page - 1) * limit;
let sessions, total;
if (req.user.role === 'teacher' || req.user.role === 'admin') {
total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions WHERE teacher_id=? AND status='ended'`).get(req.user.id).n;
sessions = db.prepare(`
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id,
u.name AS teacher_name,
c.name AS class_name,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE s.teacher_id=? AND s.status='ended'
ORDER BY s.ended_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
} else {
// student: sessions they have attendance records for
total = db.prepare(`
SELECT COUNT(DISTINCT s.id) AS n FROM classroom_sessions s
JOIN classroom_attendance a ON a.session_id=s.id
WHERE a.user_id=? AND s.status='ended'
`).get(req.user.id).n;
sessions = db.prepare(`
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id,
u.name AS teacher_name,
c.name AS class_name,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
(COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
JOIN classroom_attendance a ON a.session_id=s.id
WHERE a.user_id=? AND s.status='ended'
GROUP BY s.id ORDER BY s.ended_at DESC LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
}
res.json({ sessions, total, page, pages: Math.ceil(total / limit) });
}
/* GET /api/classroom/:id/summary */
function getSessionSummary(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const teacherName = db.prepare('SELECT name FROM users WHERE id=?').get(session.teacher_id)?.name || '';
const className = session.class_id
? db.prepare('SELECT name FROM classes WHERE id=?').get(session.class_id)?.name || null
: null;
const durationSec = session.ended_at
? Math.round((new Date(session.ended_at) - new Date(session.created_at)) / 1000)
: null;
const stats = {
duration_sec: durationSec,
participant_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_attendance WHERE session_id=?').get(sessionId).n,
message_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_chat WHERE session_id=?').get(sessionId).n,
page_count: db.prepare('SELECT COALESCE(MAX(page_num),1) AS n FROM classroom_pages WHERE session_id=?').get(sessionId).n,
stroke_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_strokes WHERE session_id=?').get(sessionId).n,
};
const attendance = db.prepare(`
SELECT a.user_id, u.name AS user_name, a.joined_at, a.left_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(sessionId).map(r => ({
...r,
duration_sec: (r.joined_at && r.left_at)
? Math.round((new Date(r.left_at) - new Date(r.joined_at)) / 1000)
: null,
}));
const pages = db.prepare(`
SELECT p.page_num, p.template, p.name,
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=p.session_id AND page_num=p.page_num) AS stroke_count
FROM classroom_pages p
WHERE p.session_id=? ORDER BY p.page_num
`).all(sessionId);
// Ensure at least page 1 appears if no pages row exists
if (!pages.length) pages.push({ page_num: 1, template: 'blank', name: null, stroke_count: stats.stroke_count });
res.json({
session: { ...session, teacher_name: teacherName, class_name: className },
stats,
attendance,
pages,
});
}
/* GET /api/classroom/:id/chat/export — plaintext chat transcript (teacher/admin) */
function exportChat(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const messages = db.prepare(`
SELECT c.created_at, u.name AS user_name, c.message, c.attachment_url
FROM classroom_chat c
JOIN users u ON u.id = c.user_id
WHERE c.session_id=? ORDER BY c.id ASC
`).all(sessionId);
const title = session.title || `Урок #${sessionId}`;
const date = session.created_at ? new Date(session.created_at).toLocaleDateString('ru-RU') : '';
let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`;
messages.forEach(m => {
const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '';
text += `[${ts}] ${m.user_name}: ${m.message || ''}`;
if (m.attachment_url) text += ` [вложение]`;
text += '\n';
});
const filename = `chat_${sessionId}_${date.replace(/\./g, '-')}.txt`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(text);
}
/* GET /api/classroom/:id/notes/all — all student notes (teacher/admin) */
function getAllNotes(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const notes = db.prepare(`
SELECT n.content, n.updated_at, u.name AS user_name, u.id AS user_id
FROM classroom_notes n
JOIN users u ON u.id = n.user_id
WHERE n.session_id=? AND n.content != '' ORDER BY u.name
`).all(sessionId);
res.json({ notes });
}
/* ── Lesson templates ───────────────────────────────────────────────────── */
/* DELETE /api/classroom/:id/history — permanently delete an ended session record */
function deleteHistorySession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.status !== 'ended') return res.status(400).json({ error: 'Можно удалять только завершённые сессии' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId);
db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_attendance WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_notes WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_sessions WHERE id=?').run(sessionId);
res.json({ ok: true });
}
/* GET /api/classroom/admin/active — all currently active sessions (admin only) */
function adminGetActiveSessions(req, res) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
const sessions = db.prepare(`
SELECT s.id, s.title, s.created_at, s.class_id, s.teacher_id,
u.name AS teacher_name,
c.name AS class_name,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id AND left_at IS NULL) AS online_count,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS total_joined,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE s.status='active'
ORDER BY s.created_at DESC
`).all();
res.json({ sessions });
}
/* GET /api/classroom/admin/sessions — all sessions paginated (admin only) */
function adminGetAllSessions(req, res) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 20);
const search = (req.query.search || '').trim();
const teacherId = (req.query.teacher || '').trim();
const classId = (req.query.class_id || '').trim();
const dateFrom = (req.query.date_from || '').trim();
const dateTo = (req.query.date_to || '').trim();
const sort = req.query.sort || 'newest';
const offset = (page - 1) * limit;
let where = "s.status='ended'";
const params = [];
if (search) { where += ` AND (s.title LIKE '%'||?||'%' OR u.name LIKE '%'||?||'%' OR c.name LIKE '%'||?||'%')`; params.push(search, search, search); }
if (teacherId) { where += ` AND s.teacher_id=?`; params.push(Number(teacherId)); }
if (classId) { where += ` AND s.class_id=?`; params.push(Number(classId)); }
if (dateFrom) { where += ` AND date(s.created_at)>=?`; params.push(dateFrom); }
if (dateTo) { where += ` AND date(s.created_at)<=?`; params.push(dateTo); }
const orderBy = sort === 'oldest' ? 's.ended_at ASC'
: sort === 'longest' ? '(julianday(s.ended_at)-julianday(s.created_at)) DESC'
: sort === 'most_students' ? 'participant_count DESC'
: 's.ended_at DESC';
const total = db.prepare(`
SELECT COUNT(*) AS n FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE ${where}
`).get(...params).n;
const sessions = db.prepare(`
SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id,
u.name AS teacher_name,
c.name AS class_name,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count,
COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1) AS page_count,
(SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE ${where}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?
`).all(...params, limit, offset);
// Server-side aggregates (filtered)
const agg = db.prepare(`
SELECT COUNT(*) AS total_sessions,
COUNT(DISTINCT s.teacher_id) AS total_teachers,
COALESCE(SUM(CASE WHEN s.ended_at IS NOT NULL AND s.created_at IS NOT NULL
THEN CAST((julianday(s.ended_at) - julianday(s.created_at)) * 86400 AS INTEGER)
ELSE 0 END), 0) AS total_duration_sec
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE ${where}
`).get(...params);
const aggPart = db.prepare(`
SELECT COALESCE(SUM(sub.pc),0) AS total_participants,
COALESCE(SUM(sub.mc),0) AS total_messages
FROM (
SELECT s.id,
(SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS pc,
(SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS mc
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
LEFT JOIN classes c ON c.id = s.class_id
WHERE ${where}
) sub
`).get(...params);
res.json({ sessions, total, page, pages: Math.ceil(total / limit),
agg: { ...agg, ...aggPart } });
}
/* GET /api/classroom/admin/teachers-list — teachers who have sessions (admin only) */
function adminGetTeachersList(req, res) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' });
const teachers = db.prepare(`
SELECT DISTINCT u.id, u.name
FROM classroom_sessions s
JOIN users u ON u.id = s.teacher_id
WHERE s.status='ended'
ORDER BY u.name
`).all();
res.json({ teachers });
}
function getTemplates(req, res) {
const templates = db.prepare(
'SELECT id, title, description, created_at FROM classroom_templates WHERE teacher_id=? ORDER BY created_at DESC'
@@ -953,6 +1463,41 @@ function loadTemplate(req, res) {
res.json({ ok: true, pages: pagesData.length });
}
/* ── Guest token: generate / revoke ───────────────────────────────────── */
function generateGuestToken(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Not found' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('UPDATE classroom_sessions SET guest_token=? WHERE id=?').run(token, sessionId);
res.json({ token, url: `/guest-board.html?token=${token}` });
}
function revokeGuestToken(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Not found' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('UPDATE classroom_sessions SET guest_token=NULL WHERE id=?').run(sessionId);
res.json({ ok: true });
}
function getGuestToken(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT id, teacher_id, guest_token FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Not found' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
if (!session.guest_token) return res.json({ token: null, url: null });
res.json({ token: session.guest_token, url: `/guest-board.html?token=${session.guest_token}` });
}
module.exports = {
createSession,
getSession,
@@ -975,12 +1520,20 @@ module.exports = {
addPage,
changePage,
updatePageTemplate,
getPages,
renamePage,
duplicatePage,
deletePage,
raiseHand,
lowerHand,
getHands,
mutePeer,
screenStart,
screenStop,
simOpen,
simClose,
simState,
simMode,
clearPage,
previewStroke,
broadcastCursor,
@@ -995,4 +1548,16 @@ module.exports = {
saveTemplate,
deleteTemplate,
loadTemplate,
getClassHistory,
getMyHistory,
getSessionSummary,
exportChat,
getAllNotes,
deleteHistorySession,
adminGetActiveSessions,
adminGetAllSessions,
adminGetTeachersList,
generateGuestToken,
revokeGuestToken,
getGuestToken,
};
+7 -2
View File
@@ -862,7 +862,8 @@ db.exec(`
('feature_knowledge_map_enabled', '1'),
('feature_board_enabled', '1'),
('feature_biochem_enabled', '1'),
('feature_live_quiz_enabled', '1');
('feature_live_quiz_enabled', '1'),
('feature_classroom_enabled', '1');
`);
/* ── Performance indexes ───────────────────────────────────────────────── */
@@ -2773,8 +2774,9 @@ db.exec(`
UNIQUE(session_id, page_num)
)
`);
// Add template column to existing classroom_pages tables (idempotent)
// Add template + name columns to existing classroom_pages tables (idempotent)
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN template TEXT NOT NULL DEFAULT 'blank'`); } catch (_) { /* already exists */ }
try { db.exec(`ALTER TABLE classroom_pages ADD COLUMN name TEXT`); } catch (_) { /* already exists */ }
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_strokes (
@@ -2854,6 +2856,9 @@ db.exec(`
)
`);
// Guest token for classroom sessions (public read-only whiteboard access)
try { db.exec("ALTER TABLE classroom_sessions ADD COLUMN guest_token TEXT UNIQUE"); } catch {}
// Persistent draw permissions (survives server restart)
db.exec(`
CREATE TABLE IF NOT EXISTS classroom_draw_permissions (
+31 -3
View File
@@ -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;
+168
View File
@@ -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;
+11 -6
View File
@@ -32,8 +32,9 @@ const flashcardRoutes = require('./routes/flashcards');
const settingsRoutes = require('./routes/settings');
const analyticsRoutes = require('./routes/analytics');
const liveRoutes = require('./routes/live');
const classroomRoutes = require('./routes/classroom');
const gamesRoutes = require('./routes/games');
const classroomRoutes = require('./routes/classroom');
const guestClassroomRoutes = require('./routes/guestClassroom');
const gamesRoutes = require('./routes/games');
const knowledgeMapRoutes = require('./routes/knowledgeMap');
const petRoutes = require('./routes/pet');
const collectionRoutes = require('./routes/collection');
@@ -77,8 +78,8 @@ app.use((_req, res, next) => {
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self' https://cdn.jsdelivr.net https://stun.l.google.com; " +
"frame-src https://www.youtube.com https://rutube.ru https://player.vimeo.com; " +
"frame-ancestors 'none'" +
"frame-src 'self' https://www.youtube.com https://rutube.ru https://player.vimeo.com; " +
"frame-ancestors 'self'" +
(isProd ? "; upgrade-insecure-requests" : "")
);
if (isProd) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
@@ -143,8 +144,9 @@ app.use('/api/search', searchRoutes);
app.use('/api/flashcards', flashcardRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/live', liveRoutes);
app.use('/api/classroom', classroomRoutes);
app.use('/api/live', liveRoutes);
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
app.use('/api/classroom', classroomRoutes);
app.use('/api/games', gamesRoutes);
app.use('/api/knowledge-map', knowledgeMapRoutes);
app.use('/api/pet', petRoutes);
@@ -308,6 +310,9 @@ app.use((_req, res) => res.status(404).sendFile(path.join(frontendDir, '404.html
const server = app.listen(PORT, () => logger.info(`Server running on port ${PORT}`, { env: config.NODE_ENV }));
/* ── WebSocket server for low-latency classroom events (cursor + preview) ── */
require('./ws-server').attach(server);
/* ── Graceful shutdown ── */
function shutdown(signal) {
logger.info(`${signal} received — shutting down gracefully`);
+37 -2
View File
@@ -1,5 +1,6 @@
/* ── SSE registry — shared between controllers ─────────────────────────── */
const clients = new Map(); // userId -> Set<res>
const clients = new Map(); // userId -> Set<res>
const guestClients = new Map(); // sessionId -> Set<res>
const db = require('./db/db');
function addClient(userId, res) {
@@ -34,6 +35,15 @@ setInterval(() => {
}
if (conns.size === 0) clients.delete(userId);
}
for (const [sessionId, conns] of guestClients) {
for (const res of conns) {
try {
if (res.writableEnded || res.destroyed) { conns.delete(res); continue; }
res.write(': heartbeat\n\n');
} catch { conns.delete(res); }
}
if (conns.size === 0) guestClients.delete(sessionId);
}
}, 30_000).unref();
/* Broadcast to all members of a class */
@@ -42,9 +52,34 @@ function emitToClass(classId, data) {
for (const { user_id } of members) emit(user_id, data);
}
/* ── Guest SSE (session-scoped, no userId) ── */
function addGuestClient(sessionId, res) {
if (!guestClients.has(sessionId)) guestClients.set(sessionId, new Set());
guestClients.get(sessionId).add(res);
}
function removeGuestClient(sessionId, res) {
const set = guestClients.get(sessionId);
if (!set) return;
set.delete(res);
if (set.size === 0) guestClients.delete(sessionId);
}
function emitToGuests(sessionId, data) {
const set = guestClients.get(sessionId);
if (!set?.size) return;
const payload = `data: ${JSON.stringify(data)}\n\n`;
for (const res of set) {
try { res.write(payload); } catch {}
}
}
/* Returns array of user IDs currently connected via SSE */
function getOnlineUserIds() {
return [...clients.keys()];
}
module.exports = { addClient, removeClient, emit, emitToClass, getOnlineUserIds };
module.exports = {
addClient, removeClient, emit, emitToClass, getOnlineUserIds,
addGuestClient, removeGuestClient, emitToGuests,
};
+159
View File
@@ -0,0 +1,159 @@
/**
* WebSocket server for low-latency real-time classroom events.
*
* Handles two message types from clients:
* { type:'cursor', sessionId, x, y, pageNum }
* { type:'preview', sessionId, liveId, tool, data, pageNum, cancel }
*
* Auth: JWT token in ?token= query param on upgrade request.
* Forwards events via SSE to session participants (no DB write).
*
* This replaces the HTTP POST /cursor and /stroke-preview endpoints
* for connected clients, reducing per-event latency from ~50-120ms to ~2-5ms.
*/
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const db = require('./db/db');
const { emit, emitToGuests } = require('./sse');
/* ── Session member cache (avoids DB query per WS message) ────────────── */
const _cache = new Map(); // sessionId → { teacherId, classId, userIds, ts }
const CACHE_TTL = 30_000;
function _getMembers(sessionId) {
const c = _cache.get(sessionId);
if (c && Date.now() - c.ts < CACHE_TTL) return c;
const session = db.prepare(
"SELECT class_id, teacher_id FROM classroom_sessions WHERE id=? AND status='active'"
).get(sessionId);
if (!session) { _cache.delete(sessionId); return null; }
let userIds;
if (session.class_id) {
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id=?').all(session.class_id);
userIds = [session.teacher_id, ...members.map(m => m.user_id)];
} else {
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
userIds = [session.teacher_id, ...invites.map(i => i.user_id)];
}
const entry = { teacherId: session.teacher_id, classId: session.class_id, userIds, ts: Date.now() };
_cache.set(sessionId, entry);
return entry;
}
function _invalidateSession(sessionId) {
_cache.delete(sessionId);
}
/* Forward serialized SSE payload to all session members */
function _broadcast(sessionId, data, includeGuests) {
const members = _getMembers(sessionId);
if (!members) return;
for (const uid of members.userIds) emit(uid, data);
if (includeGuests) emitToGuests(sessionId, data);
}
/* Check draw permissions (teacher always can; students need explicit grant) */
const _drawCache = new Map(); // `${sessionId}:${userId}` → { allowed, ts }
const DRAW_TTL = 10_000;
function _canDraw(sessionId, userId, members) {
if (!members) return false;
if (members.teacherId === userId) return true;
const key = `${sessionId}:${userId}`;
const c = _drawCache.get(key);
if (c && Date.now() - c.ts < DRAW_TTL) return c.allowed;
const allowed = !!db.prepare(
'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).get(sessionId, userId);
_drawCache.set(key, { allowed, ts: Date.now() });
return allowed;
}
/* ── WebSocket server ──────────────────────────────────────────────────── */
function attach(httpServer) {
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
/* ── Auth ── */
let user = null;
try {
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token') || '';
user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch {
ws.close(4001, 'Unauthorized');
return;
}
ws.userId = user.id;
ws.userName = user.name || user.email || '';
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const { type, sessionId } = msg;
if (!sessionId || typeof sessionId !== 'number') return;
/* ── cursor broadcast ── */
if (type === 'cursor') {
const members = _getMembers(sessionId);
if (!members || !members.userIds.includes(ws.userId)) return;
_broadcast(sessionId, {
type: 'classroom_cursor',
sessionId,
x: msg.x,
y: msg.y,
pageNum: msg.pageNum || 1,
userId: ws.userId,
userName: ws.userName,
}, true);
/* ── stroke preview broadcast ── */
} else if (type === 'preview') {
const members = _getMembers(sessionId);
if (!members) return;
if (!_canDraw(sessionId, ws.userId, members)) return;
const liveId = msg.liveId;
if (!liveId && !msg.cancel) return;
_broadcast(sessionId, {
type: 'classroom_stroke_preview',
sessionId,
pageNum: msg.pageNum || 1,
liveId,
tool: msg.tool,
data: msg.data,
cancel: msg.cancel || false,
userId: ws.userId,
userName: ws.userName,
}, true);
}
});
ws.on('error', () => {});
ws.on('close', () => {});
});
/* ── Ping/pong keepalive ── */
const pingTimer = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
try { ws.ping(); } catch {}
}
}, 30_000);
wss.on('close', () => clearInterval(pingTimer));
return wss;
}
module.exports = { attach, invalidateSession: _invalidateSession };
+426
View File
@@ -709,6 +709,124 @@
}
.sl-filter-select:focus { border-color: var(--violet); outline: none; }
.sl-count { font-size: 0.78rem; color: #8898AA; font-weight: 600; }
/* ══════════ CLASSROOM ADMIN TAB ══════════ */
.cr-admin-section { margin-bottom: 40px; }
.cr-admin-section-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.07em;
margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
}
.cr-admin-section-title::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* Active session card */
.cr-live-list { display: flex; flex-direction: column; gap: 10px; }
.cr-live-card {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1.5px solid var(--border);
border-left: 4px solid #EF4444; border-radius: 16px;
padding: 14px 18px; transition: box-shadow 0.15s, transform 0.15s;
}
.cr-live-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.1); transform: translateX(2px); }
.cr-live-pulse {
width: 10px; height: 10px; border-radius: 50%; background: #EF4444; flex-shrink: 0;
animation: pulse-live 1.4s ease-in-out infinite;
}
@keyframes pulse-live {
0%,100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); }
50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); }
}
.cr-live-info { flex: 1; min-width: 0; }
.cr-live-title { font-size: 0.96rem; font-weight: 700; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-live-meta { font-size: 0.81rem; color: var(--text-3); }
.cr-live-badges { display: flex; gap: 6px; flex-shrink: 0; align-items: center; }
.cr-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 99px; font-size: 0.76rem; font-weight: 700;
}
.cr-badge-online { background: rgba(6,214,100,0.12); color: #059652; }
.cr-badge-msgs { background: rgba(6,214,224,0.12); color: #05aab3; }
.cr-badge-dur { background: rgba(155,93,229,0.1); color: var(--violet); }
.cr-live-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* History session row */
.cr-hist-list { display: flex; flex-direction: column; gap: 8px; }
.cr-hist-row {
display: flex; align-items: center; gap: 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; padding: 12px 16px; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cr-hist-row:hover { border-color: var(--violet); box-shadow: 0 2px 12px rgba(109,40,217,0.07); }
.cr-hist-row.open { border-color: var(--violet); background: rgba(155,93,229,0.03); border-radius: 14px 14px 0 0; border-bottom: none; }
.cr-hist-icon { width: 38px; height: 38px; border-radius: 10px; background: rgba(155,93,229,0.1); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.cr-hist-main { flex: 1; min-width: 0; }
.cr-hist-title { font-size: 0.94rem; font-weight: 700; margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-hist-meta { font-size: 0.79rem; color: var(--text-3); }
.cr-hist-chips { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.cr-hist-chevron { width: 18px; height: 18px; color: var(--text-3); transition: transform 0.2s; flex-shrink: 0; }
.cr-hist-row.open .cr-hist-chevron { transform: rotate(180deg); color: var(--violet); }
/* Session detail drawer */
.cr-detail-drawer {
overflow: hidden; max-height: 0; transition: max-height 0.35s ease;
border: 1px solid var(--violet); border-top: none;
border-radius: 0 0 14px 14px; background: rgba(238,242,255,0.5);
margin-bottom: 0;
}
.cr-detail-drawer.open { max-height: 3000px; margin-bottom: 8px; }
.cr-detail-inner { padding: 20px 24px; }
.cr-detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
@media(max-width:700px) { .cr-detail-grid { grid-template-columns: repeat(2, 1fr); } }
.cr-detail-stat {
background: #fff; border: 1px solid var(--border); border-radius: 12px;
padding: 14px 16px; text-align: center;
}
.cr-detail-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: var(--violet); margin-bottom: 4px; }
.cr-detail-label { font-size: 0.72rem; color: var(--text-3); font-weight: 700; text-transform: uppercase; }
.cr-attend-list { display: flex; flex-direction: column; gap: 6px; margin-top: 12px; }
.cr-attend-row {
display: flex; align-items: center; gap: 12px; padding: 8px 12px;
border: 1px solid var(--border); border-radius: 10px; background: #fff;
font-size: 0.88rem;
}
.cr-attend-name { flex: 1; font-weight: 600; }
.cr-attend-time { color: var(--text-3); font-size: 0.8rem; }
.cr-attend-dur { color: var(--cyan); font-weight: 700; font-size: 0.8rem; min-width: 60px; text-align: right; }
.cr-pages-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px,1fr)); gap: 8px; margin-top: 10px; }
.cr-page-chip {
display: flex; align-items: center; justify-content: space-between;
background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 8px 12px; font-size: 0.82rem;
}
.cr-page-num { font-weight: 700; color: var(--violet); }
.cr-page-cnt { color: var(--text-3); font-size: 0.76rem; }
.cr-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.btn-cr-export { padding: 8px 18px; border: 1.5px solid var(--cyan); border-radius: 99px; background: rgba(6,214,224,0.06); color: #05aab3; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-export:hover { background: rgba(6,214,224,0.15); }
.btn-cr-del { padding: 8px 18px; border: 1.5px solid rgba(241,91,181,0.4); border-radius: 99px; background: transparent; color: var(--pink); font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: all 0.15s; }
.btn-cr-del:hover { background: rgba(241,91,181,0.08); border-color: var(--pink); }
.btn-cr-end { padding: 8px 18px; border: none; border-radius: 99px; background: #EF4444; color: #fff; font-family:'Manrope',sans-serif; font-size:0.82rem; font-weight:700; cursor:pointer; transition: opacity 0.15s; }
.btn-cr-end:hover { opacity: 0.85; }
/* Pagination */
.cr-pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; flex-wrap: wrap; }
.cr-page-btn {
min-width: 36px; height: 36px; padding: 0 12px; border: 1.5px solid var(--border);
border-radius: 10px; background: var(--surface); font-family:'Manrope',sans-serif;
font-size:0.85rem; font-weight:700; color:var(--text-2); cursor:pointer; transition:all 0.14s;
display:flex; align-items:center; justify-content:center;
}
.cr-page-btn:hover:not(:disabled) { border-color:var(--violet); color:var(--violet); }
.cr-page-btn.active { background:var(--violet); border-color:var(--violet); color:#fff; }
.cr-page-btn:disabled { opacity:0.4; cursor:default; }
.cr-page-info { font-size:0.82rem; color:var(--text-3); font-weight:600; }
/* toolbar for classroom history */
.cr-hist-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
.cr-hist-search { flex: 1; min-width: 180px; padding: 9px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); font-family:'Manrope',sans-serif; font-size:0.88rem; background:var(--surface); color:var(--text); }
.cr-hist-search:focus { outline:none; border-color:var(--violet); }
.cr-hist-count { font-size:0.85rem; color:var(--text-3); font-weight:600; white-space:nowrap; }
</style>
</head>
<body>
@@ -733,6 +851,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<span class="sb-link active"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></span>
</nav>
@@ -762,6 +881,9 @@
<button class="admin-nav-item" data-tab="sessions" onclick="switchTab(this)">
<i data-lucide="clock" style="width:15px;height:15px"></i> История сессий
</button>
<button class="admin-nav-item" data-tab="classroom" onclick="switchTab(this)">
<i data-lucide="video" style="width:15px;height:15px"></i> Онлайн-уроки
</button>
<div class="admin-nav-sep"></div>
<div class="admin-nav-label">Контент</div>
@@ -967,6 +1089,51 @@
<div id="t-body"><div class="spinner"></div></div>
</div>
<!-- ── Онлайн-уроки ── -->
<div class="tab-pane" id="tab-classroom">
<!-- Module master toggle -->
<div style="display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1.5px solid var(--border-h);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:32px">
<div>
<div style="font-size:0.97rem;font-weight:700;margin-bottom:4px">Модуль онлайн-уроков</div>
<div class="perm-desc" style="margin:0">Если отключить, учителя не смогут создавать новые уроки. Уже активные сессии продолжат работу до завершения.</div>
</div>
<label class="perm-toggle" id="cr-master-lbl" title="Включить / выключить модуль" style="margin-left:24px;flex-shrink:0">
<input type="checkbox" id="cr-master-chk" onchange="crMasterToggle(this.checked)" checked />
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>
<!-- Active sessions -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>
Активные уроки
<span id="cr-live-refresh-btn" style="font-size:0.76rem;font-weight:600;color:var(--violet);cursor:pointer;text-transform:none;letter-spacing:0;margin-left:-4px" onclick="loadCrActiveSessions()">
<svg class="ic" viewBox="0 0 24 24" style="width:12px;height:12px;vertical-align:-2px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Обновить
</span>
</div>
<div id="cr-live-list"><div class="spinner"></div></div>
</div>
<!-- Session history -->
<div class="cr-admin-section">
<div class="cr-admin-section-title">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
История уроков
</div>
<div class="cr-hist-toolbar">
<input class="cr-hist-search" id="cr-hist-q" type="text" placeholder="Поиск по теме или учителю…" oninput="crHistDebounce()">
<span class="cr-hist-count" id="cr-hist-count"></span>
</div>
<div id="cr-hist-list"><div class="spinner"></div></div>
<div id="cr-hist-pagination"></div>
</div>
</div>
<!-- ── Права доступа ── -->
<div class="tab-pane" id="tab-permissions">
<div class="perm-header">
@@ -4321,9 +4488,13 @@
{ id: 'magnetic', cat: 'Физика', title: 'Магнитное поле токов' },
{ id: 'circuit', cat: 'Физика', title: 'Электрические цепи' },
{ id: 'coulomb', cat: 'Физика', title: 'Закон Кулона' },
{ id: 'hydrostatics', cat: 'Физика', title: 'Гидростатика' },
{ id: 'dynamics', cat: 'Физика', title: 'Динамика' },
{ id: 'thinlens', cat: 'Физика', title: 'Тонкая линза' },
{ id: 'refraction', cat: 'Физика', title: 'Преломление света' },
{ id: 'mirrors', cat: 'Физика', title: 'Зеркала' },
{ id: 'isoprocess', cat: 'Физика', title: 'Изопроцессы' },
{ id: 'waves', cat: 'Физика', title: 'Волны и звук' },
{ id: 'molphys', cat: 'Химия', title: 'Молекулярная физика' },
{ id: 'chemistry', cat: 'Химия', title: 'Химические реакции' },
{ id: 'equilibrium', cat: 'Химия', title: 'Химическое равновесие' },
@@ -4814,6 +4985,260 @@
} catch (e) { el.innerHTML = `<div style="color:var(--pink)">${esc(e.message)}</div>`; }
}
/* ════════════════════════════════════════════════
ОНЛАЙН-УРОКИ (classroom admin)
════════════════════════════════════════════════ */
let _crHistPage = 1, _crHistTotal = 0, _crHistPages = 0, _crHistSearch = '';
let _crOpenDetailId = null, _crHistDebTimer = null;
async function loadCrModuleState() {
try {
const features = await LS.api('/api/admin/features');
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = features.classroom !== false;
} catch(e) { /* silent */ }
}
async function crMasterToggle(enabled) {
try {
await LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ classroom: enabled }) });
LS.toast(enabled ? 'Модуль онлайн-уроков включён' : 'Модуль онлайн-уроков отключён', enabled ? 'success' : 'warning', 3000);
} catch(e) {
LS.toast('Ошибка: ' + e.message, 'error');
// revert checkbox
const chk = document.getElementById('cr-master-chk');
if (chk) chk.checked = !enabled;
}
}
function fmtDuration(sec) {
if (!sec || sec < 0) return '';
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
if (h) return `${h}ч ${m}м`;
if (m) return `${m} мин ${s} сек`;
return `${s} сек`;
}
function fmtLiveDuration(createdAt) {
const sec = Math.round((Date.now() - new Date(createdAt).getTime()) / 1000);
return fmtDuration(sec);
}
async function loadCrActiveSessions() {
const el = document.getElementById('cr-live-list');
try {
const { sessions } = await LS.api('/api/classroom/admin/active');
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет активных уроков</div>';
return;
}
el.innerHTML = sessions.map(s => {
const dur = fmtLiveDuration(s.created_at);
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
return `<div class="cr-live-card">
<div class="cr-live-pulse"></div>
<div class="cr-live-info">
<div class="cr-live-title">${esc(title)}</div>
<div class="cr-live-meta">${esc(s.teacher_name)} · ${cls}</div>
</div>
<div class="cr-live-badges">
<span class="cr-badge cr-badge-online">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
${s.online_count}
</span>
<span class="cr-badge cr-badge-msgs">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
${s.message_count}
</span>
<span class="cr-badge cr-badge-dur">
<svg class="ic" viewBox="0 0 24 24" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
${dur}
</span>
</div>
<div class="cr-live-actions">
<button class="btn-cr-end" onclick="adminEndSession(${s.id})">Завершить</button>
</div>
</div>`;
}).join('');
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
async function adminEndSession(id) {
if (!await LS.confirm(`Завершить урок #${id}? Все участники будут отключены.`, { title: 'Завершить урок', confirmText: 'Завершить' })) return;
try {
await LS.api(`/api/classroom/${id}`, { method: 'DELETE' });
LS.toast('Урок завершён', 'success', 2500);
loadCrActiveSessions();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function crHistDebounce() {
clearTimeout(_crHistDebTimer);
_crHistDebTimer = setTimeout(() => { _crHistPage = 1; loadCrHistory(); }, 350);
}
async function loadCrHistory(page) {
if (page) _crHistPage = page;
_crHistSearch = (document.getElementById('cr-hist-q')?.value || '').trim();
const el = document.getElementById('cr-hist-list');
el.innerHTML = '<div class="spinner"></div>';
try {
const params = new URLSearchParams({ page: _crHistPage, limit: 20 });
if (_crHistSearch) params.set('search', _crHistSearch);
const { sessions, total, pages } = await LS.api('/api/classroom/admin/sessions?' + params);
_crHistTotal = total; _crHistPages = pages;
document.getElementById('cr-hist-count').textContent = `${total} уроков`;
if (!sessions.length) {
el.innerHTML = '<div class="empty">Нет завершённых уроков</div>';
renderCrPagination();
return;
}
el.innerHTML = sessions.map(s => {
const title = s.title || `Урок #${s.id}`;
const cls = s.class_name ? `Класс: ${esc(s.class_name)}` : 'Личный урок';
const dur = fmtDuration(s.ended_at ? Math.round((new Date(s.ended_at)-new Date(s.created_at))/1000) : null);
return `<div>
<div class="cr-hist-row${_crOpenDetailId===s.id?' open':''}" onclick="toggleCrDetail(${s.id},this)">
<div class="cr-hist-icon">
<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;color:var(--violet)"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-hist-main">
<div class="cr-hist-title">${esc(title)}</div>
<div class="cr-hist-meta">${esc(s.teacher_name)} · ${cls} · ${fmtDate(s.ended_at || s.created_at)}</div>
</div>
<div class="cr-hist-chips">
<span class="cr-badge cr-badge-online">${s.participant_count} уч.</span>
<span class="cr-badge cr-badge-msgs">${s.message_count} сообщ.</span>
<span class="cr-badge cr-badge-dur">${dur}</span>
</div>
<svg class="cr-hist-chevron ic" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</div>
<div class="cr-detail-drawer${_crOpenDetailId===s.id?' open':''}" id="cr-detail-${s.id}">
<div class="cr-detail-inner" id="cr-detail-inner-${s.id}">
<div class="spinner"></div>
</div>
</div>
</div>`;
}).join('');
if (_crOpenDetailId) {
const dr = document.getElementById(`cr-detail-${_crOpenDetailId}`);
if (dr) loadCrSessionDetail(_crOpenDetailId);
}
renderCrPagination();
} catch(e) {
el.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
if (window.lucide) lucide.createIcons();
}
function renderCrPagination() {
const el = document.getElementById('cr-hist-pagination');
if (_crHistPages <= 1) { el.innerHTML = ''; return; }
const p = _crHistPage, total = _crHistPages;
let html = '<div class="cr-pagination">';
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p-1})" ${p<=1?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="15 18 9 12 15 6"/></svg>
</button>`;
const range = [];
for (let i=1;i<=total;i++) {
if (i===1||i===total||Math.abs(i-p)<=1) range.push(i);
else if (range[range.length-1]!=='…') range.push('…');
}
range.forEach(r => {
if (r==='…') html += `<span class="cr-page-info"></span>`;
else html += `<button class="cr-page-btn${r===p?' active':''}" onclick="loadCrHistory(${r})">${r}</button>`;
});
html += `<button class="cr-page-btn" onclick="loadCrHistory(${p+1})" ${p>=total?'disabled':''}>
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="9 18 15 12 9 6"/></svg>
</button></div>`;
el.innerHTML = html;
}
async function toggleCrDetail(id, rowEl) {
const wasOpen = _crOpenDetailId === id;
// close all
document.querySelectorAll('.cr-hist-row.open').forEach(r => r.classList.remove('open'));
document.querySelectorAll('.cr-detail-drawer.open').forEach(d => { d.classList.remove('open'); d.style.maxHeight=''; });
_crOpenDetailId = null;
if (wasOpen) return;
// open this one
rowEl.classList.add('open');
const dr = document.getElementById(`cr-detail-${id}`);
if (dr) { dr.classList.add('open'); }
_crOpenDetailId = id;
await loadCrSessionDetail(id);
}
async function loadCrSessionDetail(id) {
const inner = document.getElementById(`cr-detail-inner-${id}`);
if (!inner) return;
inner.innerHTML = '<div class="spinner"></div>';
try {
const { session, stats, attendance, pages } = await LS.api(`/api/classroom/${id}/summary`);
const dur = fmtDuration(stats.duration_sec);
inner.innerHTML = `
<div class="cr-detail-grid">
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.participant_count}</div><div class="cr-detail-label">Участников</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.message_count}</div><div class="cr-detail-label">Сообщений</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val">${stats.page_count}</div><div class="cr-detail-label">Страниц</div></div>
<div class="cr-detail-stat"><div class="cr-detail-val" style="font-size:1rem">${dur}</div><div class="cr-detail-label">Длительность</div></div>
</div>
${attendance.length ? `
<div class="section-title" style="font-size:0.72rem;margin-bottom:8px">Посещаемость</div>
<div class="cr-attend-list">
${attendance.map(a => `
<div class="cr-attend-row">
<svg class="ic" viewBox="0 0 24 24" style="width:15px;height:15px;flex-shrink:0;color:var(--violet)"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span class="cr-attend-name">${esc(a.user_name)}</span>
<span class="cr-attend-time">${a.joined_at ? new Date(a.joined_at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}) : '—'}</span>
<span class="cr-attend-dur">${a.duration_sec ? fmtDuration(a.duration_sec) : (a.left_at ? '—' : '<span style="color:var(--green)">онлайн</span>')}</span>
</div>
`).join('')}
</div>
` : ''}
${pages.length > 1 ? `
<div class="section-title" style="font-size:0.72rem;margin:16px 0 8px">Страницы доски</div>
<div class="cr-pages-list">
${pages.map(p => `
<div class="cr-page-chip">
<span class="cr-page-num">Стр. ${p.page_num}</span>
<span class="cr-page-cnt">${p.stroke_count} штр.</span>
</div>
`).join('')}
</div>
` : ''}
<div class="cr-detail-actions">
<button class="btn-cr-export" onclick="adminExportChat(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Экспорт чата
</button>
<button class="btn-cr-del" onclick="adminDeleteSession(${id})">
<svg class="ic" viewBox="0 0 24 24" style="width:13px;height:13px;vertical-align:-2px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
Удалить запись
</button>
</div>`;
} catch(e) {
inner.innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function adminExportChat(id) {
window.open(`/api/classroom/${id}/chat/export`, '_blank');
}
async function adminDeleteSession(id) {
if (!await LS.confirm('Удалить всю запись об этом уроке? Данные нельзя восстановить.', { title: 'Удалить урок', confirmText: 'Удалить', dangerous: true })) return;
try {
await LS.api(`/api/classroom/${id}/history`, { method: 'DELETE' });
LS.toast('Урок удалён', 'success', 2500);
_crOpenDetailId = null;
loadCrHistory();
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── wire tab loading ─── */
const _origSwitchTab = window.switchTab;
window.switchTab = function(btn) {
@@ -4823,6 +5248,7 @@
else if (tab === 'audit') loadAuditLog();
else if (tab === 'errors') loadErrorLog();
else if (tab === 'health') loadHealth();
else if (tab === 'classroom') { loadCrModuleState(); loadCrActiveSessions(); loadCrHistory(); }
};
/* ─── init ─── */
+1
View File
@@ -262,6 +262,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link nav-active"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -287,6 +287,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -367,6 +367,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -200,6 +200,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -323,6 +323,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -341,6 +341,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -277,6 +277,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -567,6 +567,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+3655 -209
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -432,6 +432,7 @@ function renderGrid() {
<p>Откройте виды в каталоге, чтобы они появились здесь</p>
<a href="/red-book.html" class="btn-rb-outline"><svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Исследовать виды</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
</div>`;
return;
}
+1
View File
@@ -241,6 +241,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -415,6 +415,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -203,6 +203,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -1193,6 +1193,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+20 -2
View File
@@ -7,6 +7,7 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; min-height: 100vh; }
.fc-wrap { max-width: 1100px; margin: 0 auto; padding: 28px 28px 80px; }
@@ -395,6 +396,8 @@
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
(async () => {
/* ── auth ── */
@@ -664,13 +667,28 @@ async function startStudyForDeck(deckId) {
bindSwipe();
}
const _FC_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtmlFC(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _FC_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
function showStudyCard() {
const card = _studyCards[_studyIdx];
if (!card) { finishStudy(); return; }
const el = document.getElementById('study-card');
el.className = 'study-card-inner';
document.getElementById('study-front-text').textContent = card.front;
document.getElementById('study-back-text').textContent = card.back;
document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front);
document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back);
_studyFlipped = false;
document.getElementById('study-btns').classList.remove('visible');
document.getElementById('study-flip-hint').style.display = 'block';
+1
View File
@@ -215,6 +215,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+435
View File
@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Гостевой просмотр — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0b0920;
--bg2: #12093a;
--violet: #9B5DE5;
--cyan: #06D6E0;
--text: #e8e0f7;
--muted: rgba(232,224,247,0.45);
--border: rgba(155,93,229,0.2);
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: 'Manrope', sans-serif; overflow: hidden; }
/* ── Name entry screen ── */
#guest-entry {
position: fixed; inset: 0; z-index: 100;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(155,93,229,0.12) 0%, transparent 70%), var(--bg);
}
.ge-box {
width: 100%; max-width: 400px; padding: 40px 36px 36px;
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: 24px;
backdrop-filter: blur(12px);
box-shadow: 0 32px 80px rgba(0,0,0,0.55);
margin: 16px;
}
.ge-logo {
display: flex; align-items: center; gap: 10px;
font-family: 'Unbounded', sans-serif; font-size: 0.9rem; font-weight: 800;
color: var(--text); margin-bottom: 28px;
}
.ge-logo svg { width: 28px; height: 28px; }
.ge-title { font-family: 'Unbounded', sans-serif; font-size: 1.05rem; font-weight: 800; margin-bottom: 6px; }
.ge-sub { font-size: 0.78rem; color: var(--muted); margin-bottom: 26px; line-height: 1.6; }
.ge-lesson-name {
font-size: 0.82rem; font-weight: 700; color: var(--violet);
margin-bottom: 22px; padding: 8px 12px;
background: rgba(155,93,229,0.08); border-radius: 8px;
border: 1px solid rgba(155,93,229,0.18);
display: none;
}
.ge-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.ge-input {
width: 100%; padding: 12px 14px;
background: rgba(255,255,255,0.05); border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 12px; color: var(--text); font-family: 'Manrope', sans-serif;
font-size: 0.9rem; outline: none; transition: border-color 0.15s;
}
.ge-input:focus { border-color: var(--violet); }
.ge-input::placeholder { color: rgba(255,255,255,0.22); }
.ge-btn {
width: 100%; margin-top: 18px; padding: 13px;
background: linear-gradient(135deg, var(--violet), #5e2fb5);
border: none; border-radius: 12px; color: #fff;
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
cursor: pointer; transition: opacity 0.15s, transform 0.12s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.ge-btn:hover { opacity: 0.88; transform: translateY(-1px); }
.ge-btn:disabled { opacity: 0.4; cursor: default; transform: none; }
.ge-disclaimer {
margin-top: 16px; font-size: 0.68rem; color: rgba(255,255,255,0.2);
text-align: center; line-height: 1.6;
}
.ge-error { margin-top: 12px; font-size: 0.75rem; color: #FF6B6B; text-align: center; display: none; }
/* ── Board layout ── */
#guest-board { display: none; flex-direction: column; height: 100vh; }
/* Header */
.gb-header {
height: 46px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px;
background: rgba(10,7,30,0.92); border-bottom: 1px solid rgba(255,255,255,0.07);
backdrop-filter: blur(8px);
z-index: 10;
}
.gb-header-left { display: flex; align-items: center; gap: 10px; }
.gb-header-right { display: flex; align-items: center; gap: 10px; }
.gb-logo { font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800; color: var(--violet); }
.gb-title { font-size: 0.78rem; font-weight: 700; color: var(--text); max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gb-sep { width: 1px; height: 18px; background: rgba(255,255,255,0.1); }
.gb-badge {
display: flex; align-items: center; gap: 5px; padding: 3px 9px;
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.22);
border-radius: 99px; font-size: 0.63rem; font-weight: 700; color: var(--violet);
}
.gb-badge-dot { width: 5px; height: 5px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; }
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
/* Page nav */
.gb-page-nav { display: flex; align-items: center; gap: 8px; }
.gb-page-btn {
width: 28px; height: 28px; border: 1px solid rgba(255,255,255,0.12);
border-radius: 7px; background: rgba(255,255,255,0.04);
color: var(--muted); cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.gb-page-btn:hover:not(:disabled) { border-color: rgba(155,93,229,0.4); color: var(--text); background: rgba(155,93,229,0.08); }
.gb-page-btn:disabled { opacity: 0.3; cursor: default; }
.gb-page-label { font-size: 0.72rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
/* Canvas area */
.gb-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
background: #2d5a2d; /* chalkboard green — same as classroom */
}
#guest-canvas { display: block; width: 100%; height: 100%; }
/* Ended overlay */
#gb-ended {
display: none; position: fixed; inset: 0; z-index: 200;
background: rgba(11,9,32,0.92); backdrop-filter: blur(8px);
flex-direction: column; align-items: center; justify-content: center;
gap: 14px; text-align: center;
}
#gb-ended svg { width: 48px; height: 48px; color: var(--violet); }
#gb-ended h2 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; }
#gb-ended p { font-size: 0.8rem; color: var(--muted); }
/* Status toast */
#gb-toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 8px 18px; border-radius: 99px;
background: rgba(20,15,50,0.95); border: 1px solid rgba(155,93,229,0.3);
font-size: 0.76rem; font-weight: 600; color: var(--text);
pointer-events: none; opacity: 0; transition: opacity 0.25s;
white-space: nowrap; z-index: 500;
}
#gb-toast.show { opacity: 1; }
@media (max-width: 480px) {
.gb-logo { display: none; }
.gb-title { max-width: 140px; font-size: 0.72rem; }
}
</style>
</head>
<body>
<!-- ── Name entry screen ─────────────────────────────── -->
<div id="guest-entry">
<div class="ge-box">
<div class="ge-logo">
<svg viewBox="0 0 32 32" fill="none"><rect width="32" height="32" rx="8" fill="#9B5DE5"/><path d="M8 22V12l8-4 8 4v10" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M13 22v-5h6v5" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
LearnSpace
</div>
<div class="ge-title">Гостевой просмотр</div>
<div class="ge-sub">Вы смотрите доску в режиме чтения. Рисовать нельзя.</div>
<div class="ge-lesson-name" id="ge-lesson-name"></div>
<div class="ge-label">Ваше имя</div>
<input class="ge-input" id="ge-name-input" type="text" placeholder="Введите ваше имя…" maxlength="40"
onkeydown="if(event.key==='Enter') guestJoin()">
<button class="ge-btn" id="ge-join-btn" onclick="guestJoin()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
Войти как гость
</button>
<div class="ge-error" id="ge-error"></div>
<div class="ge-disclaimer">Ваше имя будет видно учителю в списке участников</div>
</div>
</div>
<!-- ── Board ─────────────────────────────────────────── -->
<div id="guest-board">
<header class="gb-header">
<div class="gb-header-left">
<span class="gb-logo">LearnSpace</span>
<div class="gb-sep"></div>
<span class="gb-title" id="gb-title">Онлайн-урок</span>
<div class="gb-badge">
<span class="gb-badge-dot"></span>
Гостевой просмотр
</div>
</div>
<div class="gb-header-right">
<div class="gb-page-nav">
<button class="gb-page-btn" id="gb-prev" onclick="gbPrevPage()" disabled title="Предыдущая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span class="gb-page-label" id="gb-page-label">1 / 1</span>
<button class="gb-page-btn" id="gb-next" onclick="gbNextPage()" disabled title="Следующая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
</header>
<div class="gb-canvas-wrap">
<canvas id="guest-canvas"></canvas>
</div>
</div>
<!-- ── Lesson ended overlay ─── -->
<div id="gb-ended">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>
<h2>Урок завершён</h2>
<p>Учитель завершил урок. Спасибо за участие!</p>
</div>
<div id="gb-toast"></div>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
<script src="/js/whiteboard.js"></script>
<script>
const _token = new URLSearchParams(location.search).get('token');
let _guestId = null;
let _sessionId = null;
let _wb = null;
let _curPage = 1;
let _totalPages = 1;
let _es = null;
let _wbMaxSeq = 0;
let _pollTimer = null;
/* ── toast ── */
let _toastTimer;
function showToast(msg, dur = 2800) {
const el = document.getElementById('gb-toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), dur);
}
/* ── pre-load session info ── */
async function init() {
if (!_token) { showError('Неверная ссылка'); return; }
try {
const r = await fetch(`/api/classroom/guest/${_token}`);
if (!r.ok) { showError('Ссылка недействительна или урок уже завершён'); return; }
const info = await r.json();
if (info.status !== 'active') {
showError('Урок ещё не начался. Попробуйте позже или обратитесь к учителю.');
return;
}
const nameEl = document.getElementById('ge-lesson-name');
nameEl.textContent = info.title || 'Онлайн-урок';
nameEl.style.display = 'block';
_totalPages = info.page_count || 1;
_curPage = info.current_page || 1;
} catch {
showError('Не удалось подключиться к серверу');
}
}
function showError(msg) {
const el = document.getElementById('ge-error');
el.textContent = msg;
el.style.display = 'block';
}
/* ── join ── */
async function guestJoin() {
const nameInput = document.getElementById('ge-name-input');
const btn = document.getElementById('ge-join-btn');
const name = nameInput.value.trim() || 'Гость';
btn.disabled = true;
try {
const r = await fetch(`/api/classroom/guest/${_token}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) {
const err = await r.json().catch(() => ({}));
showError(err.error || 'Не удалось войти');
btn.disabled = false;
return;
}
const data = await r.json();
_guestId = data.guestId;
_sessionId = data.sessionId;
_totalPages = data.page_count || 1;
_curPage = data.current_page || 1;
document.getElementById('gb-title').textContent = data.title || 'Онлайн-урок';
startBoard();
} catch {
showError('Ошибка соединения');
btn.disabled = false;
}
}
/* ── board startup ── */
function startBoard() {
document.getElementById('guest-entry').style.display = 'none';
const boardEl = document.getElementById('guest-board');
boardEl.style.display = 'flex';
const canvas = document.getElementById('guest-canvas');
_wb = new Whiteboard(canvas, { readOnly: true, bg: 'chalk' });
_wb.fit();
window.addEventListener('resize', () => _wb.fit());
loadPage(_curPage);
connectSSE();
// Send goodbye on tab close
window.addEventListener('pagehide', leaveGuest);
}
/* ── load strokes for a page ── */
async function loadPage(pageNum) {
_curPage = pageNum;
updatePageNav();
_wbMaxSeq = 0;
_wb.clearPage();
try {
const r = await fetch(`/api/classroom/guest/${_token}/strokes?page_num=${pageNum}`);
if (!r.ok) return;
const data = await r.json();
_wbMaxSeq = data.seq || 0;
_wb.loadStrokes(data.strokes || []);
if (data.template) _wb.setTemplate(data.template);
} catch {}
}
function updatePageNav() {
document.getElementById('gb-page-label').textContent = `${_curPage} / ${_totalPages}`;
document.getElementById('gb-prev').disabled = _curPage <= 1;
document.getElementById('gb-next').disabled = _curPage >= _totalPages;
}
function gbPrevPage() { if (_curPage > 1) loadPage(_curPage - 1); }
function gbNextPage() { if (_curPage < _totalPages) loadPage(_curPage + 1); }
/* ── SSE ── */
function connectSSE() {
const url = `/api/classroom/guest/${_token}/stream?guestId=${encodeURIComponent(_guestId || '')}`;
_es = new EventSource(url);
_es.onmessage = (e) => {
try { handleEvent(JSON.parse(e.data)); } catch {}
};
_es.onerror = () => {
// Auto-reconnects — just show brief toast
setTimeout(() => { if (_es.readyState === EventSource.CONNECTING) showToast('Переподключение…'); }, 3000);
};
}
function handleEvent(data) {
if (!data.type) return;
switch (data.type) {
case 'classroom_strokes':
if (data.pageNum == _curPage) {
_wbMaxSeq = Math.max(_wbMaxSeq, ...(data.strokes || []).map(s => s.seq || 0));
_wb.addStrokes(data.strokes || []);
}
break;
case 'classroom_stroke_preview':
if (data.pageNum == _curPage) {
if (data.cancel) _wb.removeLiveStroke(data.liveId);
else _wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, '#06D6E0');
}
break;
case 'classroom_stroke_deleted':
_wb.removeStroke(data.strokeId);
break;
case 'classroom_stroke_updated':
if (data.pageNum == _curPage)
_wb.updateStroke(data.strokeId, data.data);
break;
case 'classroom_page_added':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_changed':
// Follow teacher
if (data.pageNum !== _curPage) loadPage(data.pageNum);
break;
case 'classroom_template_changed':
if (data.pageNum == _curPage) _wb.setTemplate(data.template);
break;
case 'classroom_page_cleared':
if (data.pageNum == _curPage) { _wbMaxSeq = 0; _wb.clearPage(); }
break;
case 'classroom_page_renamed':
// Nothing visible to guest
break;
case 'classroom_page_duplicated':
_totalPages++;
updatePageNav();
break;
case 'classroom_page_deleted':
_totalPages = Math.max(1, _totalPages - 1);
if (_curPage > _totalPages) loadPage(_totalPages);
else updatePageNav();
break;
case 'classroom_ended':
showEnded();
break;
}
}
function showEnded() {
if (_es) { _es.close(); _es = null; }
document.getElementById('gb-ended').style.display = 'flex';
}
function leaveGuest() {
if (!_guestId) return;
navigator.sendBeacon(`/api/classroom/guest/${_token}/leave`,
new Blob([JSON.stringify({ guestId: _guestId })], { type: 'application/json' }));
}
/* ── boot ── */
init();
</script>
</body>
</html>
+1
View File
@@ -285,6 +285,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -166,6 +166,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+27 -5
View File
@@ -20,12 +20,14 @@
* rtc.destroy();
*/
class ClassroomRTC {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive }) {
constructor({ sessionId, userId, onSignal, onScreenStream, onMicActive, onMicLevel, vadThreshold }) {
this._sid = sessionId;
this._uid = userId;
this._onSignal = onSignal; // fn(targetUid, payload)
this._onScreen = onScreenStream; // fn(stream | null)
this._onMicActive = onMicActive; // fn(uid, bool) — optional
this._onMicLevel = onMicLevel; // fn(uid, level 0-100) — optional, fires every tick
this._vadThreshold = vadThreshold ?? 12;
this._peers = new Map(); // uid → PeerState
this._localStream = null; // mic audio
@@ -62,15 +64,17 @@ class ClassroomRTC {
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
let speaking = false;
const THRESHOLD = 12;
const THRESHOLD = this._vadThreshold;
const timer = setInterval(() => {
analyser.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
const level = Math.min(100, Math.round((avg / 64) * 100));
const now = avg > THRESHOLD;
if (now !== speaking) {
speaking = now;
try { this._onMicActive(uid, speaking); } catch {}
try { if (this._onMicActive) this._onMicActive(uid, speaking); } catch {}
}
try { if (this._onMicLevel) this._onMicLevel(uid, level); } catch {}
}, 120);
this._vadTimers.set(uid, { ctx, timer });
} catch {}
@@ -307,10 +311,11 @@ class ClassroomRTC {
/* ── Screen sharing (teacher) ────────────────────────────────────────── */
async startScreenShare() {
async startScreenShare(constraints = {}) {
try {
this._screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { cursor: 'always' }, audio: false,
video: { cursor: 'always', ...constraints.video },
audio: constraints.audio ?? false,
});
} catch { return null; }
@@ -340,6 +345,23 @@ class ClassroomRTC {
}
}
/** Take an already-acquired MediaStream (e.g. from a pre-picker) and share it. */
async useExistingScreenStream(stream) {
if (this._screenStream) {
this._screenStream.getTracks().forEach(t => t.stop());
this._screenStream = null;
}
this._screenStream = stream;
const vt = this._screenStream.getVideoTracks()[0];
if (!vt) return stream;
for (const [, peer] of this._peers) {
if (!peer.screenSender) {
peer.screenSender = peer.pc.addTrack(vt, this._screenStream);
}
}
return stream;
}
isSharing() { return !!this._screenStream; }
/* ── Cleanup ─────────────────────────────────────────────────────────── */
+1 -1
View File
@@ -792,7 +792,7 @@ class AngryBirdsSim {
/* Planet + g — second line, readable */
ctx.font = '13px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillText(`${pl.label} g = ${pl.g} м/с²`, 14, 50);
ctx.fillText(`${pl.label.replace(/<svg[\s\S]*?<\/svg>/g, '').trim()} g = ${pl.g} м/с²`, 14, 50);
/* Wind reminder */
if (lvl?.wind) {
+1
View File
@@ -50,6 +50,7 @@ class BohrAtomSim {
this.W = w; this.H = h;
}
getParams() { return { level: this.level }; }
setParams({ level } = {}) {
if (level !== undefined) {
const n = Math.max(1, Math.min(6, Math.round(+level)));
+16 -4
View File
@@ -1,4 +1,16 @@
'use strict';
/* Strip SVG markup for canvas fillText — replaces icon SVGs with Unicode */
function _csClean(s) {
if (!s || !s.includes('<svg')) return s;
return s.replace(/<svg[\s\S]*?<\/svg>/g, m => {
if (m.includes('x1="5" y1="12" x2="19"')) return '\u2192'; // → right arrow
if (m.includes('x1="12" y1="5" x2="12" y2="19"')) return '\u2193'; // ↓ down (precip)
if (m.includes('x1="12" y1="19" x2="12" y2="5"')) return '\u2191'; // ↑ up (gas)
return '';
});
}
/*
ChemSandboxSim v2 «Химическая песочница»
Колба Эрленмейера с реалистичным стеклом
@@ -1089,7 +1101,7 @@ class ChemSandboxSim {
// ── Молекулярное уравнение ──
ctx.font = 'bold 17px "JetBrains Mono", monospace';
ctx.fillStyle = rx.fx.none ? 'rgba(255,100,100,0.75)' : 'rgba(255,255,255,0.95)';
ctx.fillText(rx.eq, W / 2, y);
ctx.fillText(_csClean(rx.eq), W / 2, y);
y += 22;
// ── Тип реакции + пояснение ──
@@ -1109,7 +1121,7 @@ class ChemSandboxSim {
if (rx.why) {
ctx.font = '13px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.55)';
ctx.fillText(rx.why, W / 2, y);
ctx.fillText(_csClean(rx.why), W / 2, y);
y += 17;
}
@@ -1117,7 +1129,7 @@ class ChemSandboxSim {
if (rx.ionFull) {
ctx.font = '13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(155,200,255,0.60)';
ctx.fillText('Полн.: ' + rx.ionFull, W / 2, y);
ctx.fillText('Полн.: ' + _csClean(rx.ionFull), W / 2, y);
y += 16;
}
@@ -1125,7 +1137,7 @@ class ChemSandboxSim {
if (rx.ionNet) {
ctx.font = 'bold 13px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(123,245,164,0.75)';
ctx.fillText('Сокр.: ' + rx.ionNet, W / 2, y);
ctx.fillText('Сокр.: ' + _csClean(rx.ionNet), W / 2, y);
y += 16;
}
+1
View File
@@ -74,6 +74,7 @@ class CollisionSim {
this.c.height = r.height || 420;
}
getParams() { return { m1: this.m1, m2: this.m2, v1: this.v1, v2: this.v2, angle: this.angle, e: this.e }; }
setParams(p) {
if (p.m1 !== undefined) this.m1 = +p.m1;
if (p.m2 !== undefined) this.m2 = +p.m2;
+1
View File
@@ -86,6 +86,7 @@ class ElectrolysisSim {
this._initIons();
}
getParams() { return { voltage: this.voltage, electrolyte: this.electrolyte }; }
setParams({ voltage, electrolyte } = {}) {
if (voltage !== undefined) this.voltage = Math.max(1, Math.min(12, +voltage));
if (electrolyte !== undefined) {
+1
View File
@@ -48,6 +48,7 @@ class EquilibriumSim {
this.reset();
}
getParams() { return { T: this.T, nA: this.nA, nB: this.nB, Ea_f: this.Ea_f, Ea_r: this.Ea_r }; }
setParams({ T, nA, nB, Ea_f, Ea_r } = {}) {
let needReset = false;
if (T !== undefined) this.T = Math.max(200, Math.min(500, +T));
+2 -2
View File
@@ -1787,7 +1787,7 @@ class ForceSandboxSim {
// Угловая скорость ω — фиолетовая метка справа от тела
if (hasOmg) {
const sym = b.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = b.omega > 0 ? '\u21BB' : '\u21BA';
const labX = b.x + halfW + 7;
const labY = b.type === 'box' ? b.y - b.h * 0.12 : b.y - b.r * 0.15;
ctx.save();
@@ -1852,7 +1852,7 @@ class ForceSandboxSim {
ctx.fillStyle = '#EF476F';
ctx.fillText(`p=${(body.mass * spd / S).toFixed(1)} кг·м/с`, cx, cy + r + 12);
if (Math.abs(body.omega) > 0.05) {
const sym = body.omega > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.49-4.12"/></svg>' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4.12"/></svg>';
const sym = body.omega > 0 ? '\u21BB' : '\u21BA';
ctx.fillStyle = '#9B5DE5';
ctx.fillText(`${sym} ω=${Math.abs(body.omega).toFixed(2)} рад/с`, cx, cy + r + 22);
}
+1
View File
@@ -46,6 +46,7 @@ class GraphTransformSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, k: this.k, b: this.b, c: this.c }; }
setParams({ a, k, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (k !== undefined) this.k = +k;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -56,6 +56,7 @@ class IsoprocessSim {
setProcess(p) { this.process = p; this.draw(); this._emit(); }
setGamma(g) { this.gamma = +g; this.draw(); this._emit(); }
getParams() { return { P1: this.P1, V1: this.V1, process: this.process }; }
setParams({ P1, V1 } = {}) {
if (P1 !== undefined) this.P1 = Math.max(0.4, Math.min(8, +P1));
if (V1 !== undefined) this.V1 = Math.max(2, Math.min(28, +V1));
+1
View File
@@ -84,6 +84,7 @@ class MirrorSim {
this.draw(); this._emit();
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(30, Math.min(300, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(490, +d));
+1
View File
@@ -34,6 +34,7 @@ class NormalDistSim {
this.W = w; this.H = h;
}
getParams() { return { mu: this.mu, sigma: this.sigma, shade: this.shade, zLow: this.zLow, zHigh: this.zHigh }; }
setParams({ mu, sigma, shade, zLow, zHigh } = {}) {
if (mu !== undefined) this.mu = +mu;
if (sigma !== undefined) this.sigma = Math.max(0.1, +sigma);
+3
View File
@@ -51,6 +51,9 @@ class PendulumSim {
this.W = w; this.H = h;
}
getParams() {
return { L: this.L, g: this.g, theta: +(this.theta * 180 / Math.PI).toFixed(3), damping: this.damping };
}
setParams({ L, g, theta, damping } = {}) {
if (L !== undefined) this.L = +L;
if (g !== undefined) this.g = +g;
+1
View File
@@ -60,6 +60,7 @@ class ProbabilitySim {
this.W = w; this.H = h;
}
getParams() { return { mode: this.mode, trials: this.trials, speed: this.speed }; }
setParams({ mode, trials, speed } = {}) {
if (mode !== undefined) this.mode = mode;
if (trials !== undefined) this.trials = Math.max(1, +trials);
+5
View File
@@ -88,6 +88,11 @@ class ProjectileSim {
this._cw = w; this._ch = h;
}
getParams() {
return { v0: this.v0, angle: this.angle, h0: this.h0, g: this.g,
drag: this.drag, Cd: this.Cd, mass: this.mass, wind: this.wind,
bounce: this.bounce, restitution: this.restitution };
}
setParams({ v0, angle, h0, g, drag, Cd, mass, wind, bounce, restitution } = {}) {
if (v0 !== undefined) this.v0 = +v0;
if (angle !== undefined) this.angle = +angle;
+1
View File
@@ -43,6 +43,7 @@ class QuadraticSim {
this.W = w; this.H = h;
}
getParams() { return { a: this.a, b: this.b, c: this.c }; }
setParams({ a, b, c } = {}) {
if (a !== undefined) this.a = +a;
if (b !== undefined) this.b = +b;
+1
View File
@@ -42,6 +42,7 @@ class RefractionSim {
this.W = w; this.H = h;
}
getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; }
setParams({ n1, n2, angle, dispersion } = {}) {
if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1));
if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2));
+1
View File
@@ -39,6 +39,7 @@ class ThinLensSim {
this.W = w; this.H = h;
}
getParams() { return { f: this.f, d: this.d, h: this.h }; }
setParams({ f, d, h } = {}) {
if (f !== undefined) this.f = Math.max(-200, Math.min(200, +f));
if (d !== undefined) this.d = Math.max(30, Math.min(400, +d));
+1
View File
@@ -67,6 +67,7 @@ class TitrationSim {
/* ── Public API ─────────────────────────────────────────── */
getParams() { return { acidConc: this.acidConc, baseConc: this.baseConc, acidVol: this.acidVol, indicator: this.indicator, acidType: this.acidType }; }
setParams({ acidConc, baseConc, acidVol, indicator, acidType } = {}) {
if (acidConc !== undefined) this.acidConc = Math.max(0.05, Math.min(1.0, +acidConc));
if (baseConc !== undefined) this.baseConc = Math.max(0.05, Math.min(1.0, +baseConc));
+2 -2
View File
@@ -792,7 +792,7 @@ class TriangleSim {
// Formula
this._drawFormulaBox(ctx, this.W, this.H,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² ${adjSide1Name}·${adjSide2Name}·cos${angName} <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> ${c2.toFixed(2)} = ${check.toFixed(2)}`,
`${oppSideName}² = ${adjSide1Name}² + ${adjSide2Name}² \u2212 2\u00B7${adjSide1Name}\u00B7${adjSide2Name}\u00B7cos${angName} \u2192 ${c2.toFixed(2)} = ${check.toFixed(2)}`,
'#fbbf24');
ctx.restore();
@@ -845,7 +845,7 @@ class TriangleSim {
const diff = Math.abs(hypArea - (leg1Area + leg2Area));
const statusCol = isRight ? '#22d55e' : '#f59e0b';
const statusText = isRight
? `<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> ${leg1Name}² + ${leg2Name}² = ${hypName}² (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
? `\u2713 ${leg1Name}\u00B2 + ${leg2Name}\u00B2 = ${hypName}\u00B2 (${(leg1Area + leg2Area).toFixed(2)} = ${hypArea.toFixed(2)})`
: `${leg1Name}² + ${leg2Name}² = ${(leg1Area + leg2Area).toFixed(2)}${hypName}² = ${hypArea.toFixed(2)} (Δ = ${diff.toFixed(2)})`;
this._drawFormulaBox(ctx, this.W, this.H, statusText, statusCol);
+4
View File
@@ -59,6 +59,10 @@ class WavesSim {
this._emit();
}
getParams() {
return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2,
n: this._n, speed: this._speed, mode: this._mode };
}
setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) {
if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1));
if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1));
+1523 -192
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -380,6 +380,7 @@
<span class="sb-link active"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></span>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+366 -6
View File
@@ -136,11 +136,12 @@
.sim-zoom-btns { display: flex; gap: 4px; }
.zoom-btn {
width: 32px; height: 32px; border-radius: 10px;
min-width: 32px; width: auto; height: 32px; border-radius: 10px;
border: 1.5px solid var(--border-h);
background: transparent; color: var(--text-2);
cursor: pointer; font-size: 1.1rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: .8rem; font-weight: 700;
padding: 0 9px; white-space: nowrap;
display: flex; align-items: center; justify-content: center; gap: 4px;
transition: all .15s;
}
.zoom-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.07); }
@@ -668,6 +669,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -933,6 +935,54 @@
</button>
</div>
<!-- hydrostatics controls -->
<div id="ctrl-hydro" class="sim-zoom-btns" style="display:none">
<select id="hydro-mode-sel" onchange="hydroMode(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="pressure">Давление P=ρgh</option>
<option value="surface">Пов. натяжение</option>
<option value="communicating">Сообщ. сосуды</option>
<option value="archimedes">Архимед</option>
</select>
<select id="hydro-liq-sel" onchange="hydroSim&&hydroSim.setLiquid(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="water">Вода</option>
<option value="saltwater">Солёная вода</option>
<option value="oil">Масло</option>
<option value="alcohol">Спирт</option>
<option value="glycerin">Глицерин</option>
<option value="mercury">Ртуть</option>
</select>
<div id="hydro-arch-ctrl" style="display:none;gap:4px;align-items:center">
<select id="hydro-mat-sel" onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 8px;font-size:.72rem;cursor:pointer">
<option value="styrofoam">Пенопласт</option>
<option value="cork">Пробка</option>
<option value="wood">Дерево</option>
<option value="ice">Лёд</option>
<option value="plastic">Пластик</option>
<option value="glass">Стекло</option>
<option value="aluminum">Алюминий</option>
<option value="iron">Железо</option>
<option value="gold">Золото</option>
</select>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.addBody()" title="Добавить тело">+ Тело</button>
<button class="zoom-btn" onclick="hydroSim&&hydroSim.clearBodies()" title="Очистить">Очистить</button>
</div>
<div id="hydro-comm-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5)">Сосудов:</label>
<select onchange="hydroSim&&hydroSim.setNumVessels(+this.value)" style="background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.15);border-radius:7px;padding:3px 6px;font-size:.72rem;cursor:pointer">
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
<button class="zoom-btn" id="hydro-valve-btn" onclick="hydroToggleValve()" title="Кран">Кран: откр.</button>
</div>
<div id="hydro-surf-ctrl" style="display:none;gap:4px;align-items:center">
<label style="font-size:.72rem;color:rgba(255,255,255,.5);white-space:nowrap">θ:</label>
<input type="range" min="0" max="160" value="20" step="5" style="width:72px;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.querySelector('#hydro-panel-theta input[type=range]').value=this.value">
<span id="hydro-theta-val" style="font-size:.72rem;color:#9B5DE5;min-width:28px;white-space:nowrap">20°</span>
<button class="zoom-btn" id="hydro-surf-toggle" onclick="hydroToggleSurface()" title="Переключить: капилляры / капля" style="white-space:nowrap">Капилляры</button>
</div>
</div>
<!-- theory toggle (all sims) -->
<button class="zoom-btn" id="theory-toggle" onclick="toggleTheory()" title="Теория и формулы" style="margin-left:auto">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M2 3h6a4 4 0 014 4v14a3 3 0 00-3-3H2z"/><path d="M22 3h-6a4 4 0 00-4 4v14a3 3 0 013-3h7z"/></svg>
@@ -3537,6 +3587,97 @@
<div class="pstat"><div class="pstat-label">Диагональ</div><div class="pstat-val" id="stbar-d"></div></div>
</div>
<!-- ── HYDROSTATICS sim body ── -->
<div id="sim-hydro" class="sim-proj-wrap" style="display:none">
<div class="sim-body-wrap">
<!-- left panel -->
<div class="proj-panel" style="width:230px;gap:0;overflow-y:auto">
<div class="gp-section-title" style="margin-bottom:8px">Параметры</div>
<!-- liquid -->
<div style="margin-bottom:10px">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Жидкость</div>
<select onchange="hydroSim&&hydroSim.setLiquid(this.value);document.getElementById('hydro-liq-sel').value=this.value" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="water">Вода (1000 кг/м³)</option>
<option value="saltwater">Солёная вода (1030)</option>
<option value="oil">Масло (900)</option>
<option value="alcohol">Спирт (790)</option>
<option value="glycerin">Глицерин (1260)</option>
<option value="mercury">Ртуть (13600)</option>
</select>
</div>
<!-- material (Archimedes only) -->
<div id="hydro-panel-mat" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Материал тела</div>
<select onchange="hydroSim&&hydroSim.setMaterial(this.value)" style="width:100%;background:#1a1030;color:#f0e8ff;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:5px 8px;font-size:.78rem">
<option value="styrofoam">Пенопласт (30 кг/м³)</option>
<option value="cork">Пробка (120)</option>
<option value="wood">Дерево (500)</option>
<option value="ice">Лёд (900)</option>
<option value="plastic">Пластик (1100)</option>
<option value="glass">Стекло (2500)</option>
<option value="aluminum">Алюминий (2700)</option>
<option value="iron">Железо (7800)</option>
<option value="gold">Золото (19300)</option>
</select>
<div style="display:flex;gap:5px;margin-top:6px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addBody()" style="flex:1">+ Тело</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.clearBodies()" style="flex:1">Очистить</button>
</div>
</div>
<!-- contact angle (surface tension) -->
<div id="hydro-panel-theta" style="margin-bottom:10px;display:none">
<div style="display:flex;justify-content:space-between;font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">
<span>Краевой угол θ</span>
<span id="hydro-theta-lbl" style="color:#9B5DE5">20°</span>
</div>
<input type="range" min="0" max="160" value="20" step="5" style="width:100%;accent-color:#9B5DE5" oninput="hydroSim&&hydroSim.setContactAngle(+this.value);document.getElementById('hydro-theta-lbl').textContent=this.value+'\u00B0';document.getElementById('hydro-theta-val').textContent=this.value+'\u00B0';document.querySelector('#hydro-surf-ctrl input[type=range]').value=this.value">
<div style="display:flex;justify-content:space-between;font-size:.65rem;color:rgba(255,255,255,.25);margin-top:2px">
<span>Смачивание</span><span>Несмачивание</span>
</div>
<div style="margin-top:6px">
<button class="gp-btn" id="hydro-surf-toggle-panel" onclick="hydroToggleSurface()" style="width:100%">Капилляры</button>
</div>
</div>
<!-- communicating vessels -->
<div id="hydro-panel-comm" style="margin-bottom:10px;display:none">
<div style="font-size:.72rem;color:rgba(255,255,255,.4);margin-bottom:4px">Сосудов</div>
<div style="display:flex;gap:5px">
<button class="gp-btn hydro-nv active" onclick="hydroSetVessels(2,this)" style="flex:1">2</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(3,this)" style="flex:1">3</button>
<button class="gp-btn hydro-nv" onclick="hydroSetVessels(4,this)" style="flex:1">4</button>
</div>
<div style="margin-top:8px">
<button class="gp-btn" id="hydro-valve-panel-btn" onclick="hydroToggleValve()" style="width:100%;color:#06D6A0;border-color:rgba(6,214,160,.3)">Кран: открыт</button>
</div>
<div style="margin-top:6px;display:flex;gap:5px">
<button class="gp-btn" onclick="hydroSim&&hydroSim.addLiquid(0)" style="flex:1">+ Жидкость</button>
<button class="gp-btn" onclick="hydroSim&&hydroSim.removeLiquid()" style="flex:1">- Жидкость</button>
</div>
</div>
<!-- formula display -->
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Формулы</div>
<div id="hydro-formulas" style="font-size:.72rem;font-family:'JetBrains Mono',monospace;color:rgba(255,255,255,.6);line-height:1.7;background:rgba(255,255,255,.03);border-radius:8px;padding:8px 10px;min-height:80px"></div>
<!-- result badge -->
<div id="hydro-result" style="margin-top:8px;font-size:.82rem;font-weight:700;text-align:center;padding:8px;border-radius:8px;display:none"></div>
</div><!-- /.proj-panel -->
<!-- canvas area -->
<div style="flex:1;min-width:0;position:relative">
<canvas id="hydro-canvas" style="width:100%;height:100%;display:block"></canvas>
</div>
</div><!-- /.sim-body-wrap -->
</div><!-- /#sim-hydro -->
<!-- ── Theory panel (overlay right) ── -->
<div class="theory-panel" id="theory-panel">
<div class="theory-panel-inner" id="theory-content"></div>
@@ -4152,6 +4293,10 @@
title: 'Закон Кулона',
desc: 'Силовые линии и эквипотенциальные поверхности для системы точечных зарядов.',
preview: P_FIELD },
{ id: 'hydrostatics', cat: 'phys',
title: 'Гидростатика',
desc: 'Давление жидкости P=ρgh, закон Архимеда, сообщающиеся сосуды, поверхностное натяжение и капиллярность.',
preview: P_SANDBOX },
{ id: 'dynamics', cat: 'phys',
title: 'Динамика',
desc: 'Законы Ньютона, песочница сил, наклонная плоскость — всё в одном интерактивном модуле.',
@@ -4269,11 +4414,11 @@
'sim-quadratic','sim-normaldist','sim-graphtransform',
'sim-pendulum','sim-equilibrium','sim-thinlens','sim-titration',
'sim-refraction','sim-mirrors','sim-isoprocess','sim-probability','sim-bohratom','sim-electrolysis',
'sim-waves'];
'sim-waves','sim-hydro'];
const ALL_CTRL_BARS = ['ctrl-graph','ctrl-proj','ctrl-coll','ctrl-tri','ctrl-trigcircle','ctrl-mag',
'ctrl-molphys',
'ctrl-coulomb','ctrl-circuit','ctrl-chemistry','ctrl-dynamics','ctrl-chemsandbox',
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves'];
'ctrl-celldivision','ctrl-photosynthesis','ctrl-angrybirds','ctrl-waves','ctrl-hydro'];
/* ── sim routing ── */
@@ -4324,6 +4469,8 @@
if (id === 'bohratom') _openBohrAtom();
if (id === 'electrolysis') _openElectrolysis();
if (id === 'waves') _openWaves();
if (id === 'hydrostatics') _openHydro();
if (id.startsWith('hydrostatics:')) _openHydro(id.split(':')[1]);
}
function _simShow(elId) {
@@ -4423,6 +4570,21 @@
_simShow('sim-graph');
_simShow('ctrl-graph');
_registerSimState('graph',
() => ({
fns: [0,1,2].map(i => ({ expr: document.getElementById(`fn${i}`)?.value || '', color: FN_COLORS[i] }))
}),
(st) => {
if (!Array.isArray(st.fns)) return;
st.fns.forEach((fn, i) => {
const el = document.getElementById(`fn${i}`);
if (el) { el.value = fn.expr; }
if (gSim) gSim.setFn(i, fn.expr, FN_COLORS[i]);
});
}
);
if (_embedMode) _startStateEmit('graph');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gSim) {
gSim = new GraphSim(document.getElementById('graph-canvas'));
@@ -4446,6 +4608,8 @@
document.getElementById('sim-topbar-title').textContent = 'Бросок тела';
_simShow('sim-proj');
_simShow('ctrl-proj');
_registerSimState('projectile', () => pSim?.getParams(), st => pSim?.setParams(st));
if (_embedMode) _startStateEmit('projectile');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pSim) {
@@ -4629,6 +4793,8 @@
document.getElementById('sim-topbar-title').textContent = 'Столкновение шаров';
_simShow('sim-coll');
_simShow('ctrl-coll');
_registerSimState('collision', () => cSim?.getParams(), st => cSim?.setParams(st));
if (_embedMode) _startStateEmit('collision');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!cSim) {
@@ -6535,6 +6701,8 @@
function _openQuadratic() {
document.getElementById('sim-topbar-title').textContent = 'Корни квадратного уравнения';
_simShow('sim-quadratic');
_registerSimState('quadratic', () => quadSim?.getParams(), st => quadSim?.setParams(st));
if (_embedMode) _startStateEmit('quadratic');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!quadSim) {
quadSim = new QuadraticSim(document.getElementById('quadratic-canvas'));
@@ -6573,6 +6741,8 @@
function _openNormalDist() {
document.getElementById('sim-topbar-title').textContent = 'Нормальное распределение';
_simShow('sim-normaldist');
_registerSimState('normaldist', () => ndSim?.getParams(), st => ndSim?.setParams(st));
if (_embedMode) _startStateEmit('normaldist');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!ndSim) {
ndSim = new NormalDistSim(document.getElementById('normaldist-canvas'));
@@ -6617,6 +6787,8 @@
function _openGraphTransform() {
document.getElementById('sim-topbar-title').textContent = 'Трансформации графиков';
_simShow('sim-graphtransform');
_registerSimState('graphtransform', () => gtSim?.getParams(), st => gtSim?.setParams(st));
if (_embedMode) _startStateEmit('graphtransform');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gtSim) {
gtSim = new GraphTransformSim(document.getElementById('graphtransform-canvas'));
@@ -6663,6 +6835,8 @@
function _openPendulum() {
document.getElementById('sim-topbar-title').textContent = 'Маятник';
_simShow('sim-pendulum');
_registerSimState('pendulum', () => pendSim?.getParams(), st => pendSim?.setParams(st));
if (_embedMode) _startStateEmit('pendulum');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!pendSim) {
pendSim = new PendulumSim(document.getElementById('pendulum-canvas'));
@@ -6705,6 +6879,8 @@
function _openEquilibrium() {
document.getElementById('sim-topbar-title').textContent = 'Химическое равновесие';
_simShow('sim-equilibrium');
_registerSimState('equilibrium', () => eqSim?.getParams(), st => eqSim?.setParams(st));
if (_embedMode) _startStateEmit('equilibrium');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!eqSim) {
eqSim = new EquilibriumSim(document.getElementById('equilibrium-canvas'));
@@ -6746,6 +6922,8 @@
function _openThinLens() {
document.getElementById('sim-topbar-title').textContent = 'Тонкая линза';
_simShow('sim-thinlens');
_registerSimState('thinlens', () => lensSim?.getParams(), st => lensSim?.setParams(st));
if (_embedMode) _startStateEmit('thinlens');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!lensSim) {
lensSim = new ThinLensSim(document.getElementById('thinlens-canvas'));
@@ -6787,6 +6965,8 @@
function _openMirror() {
document.getElementById('sim-topbar-title').textContent = 'Зеркала';
_simShow('sim-mirrors');
_registerSimState('mirrors', () => mirrorSim?.getParams(), st => mirrorSim?.setParams(st));
if (_embedMode) _startStateEmit('mirrors');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!mirrorSim) {
mirrorSim = new MirrorSim(document.getElementById('mirror-canvas'));
@@ -6877,6 +7057,9 @@
function _openIsoprocess() {
document.getElementById('sim-topbar-title').textContent = 'Изопроцессы';
_simShow('sim-isoprocess');
_registerSimState('isoprocess', () => isoSim?.getParams(),
st => { if (isoSim) { isoSim.setParams(st); if (st.process) isoSim.setProcess(st.process); } });
if (_embedMode) _startStateEmit('isoprocess');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!isoSim) {
isoSim = new IsoprocessSim(document.getElementById('isoprocess-canvas'));
@@ -6941,6 +7124,8 @@
function _openTitration() {
document.getElementById('sim-topbar-title').textContent = 'pH и кривая титрования';
_simShow('sim-titration');
_registerSimState('titration', () => titrSim?.getParams(), st => titrSim?.setParams(st));
if (_embedMode) _startStateEmit('titration');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!titrSim) {
titrSim = new TitrationSim(document.getElementById('titration-canvas'));
@@ -6989,6 +7174,8 @@
function _openRefraction() {
document.getElementById('sim-topbar-title').textContent = 'Преломление света';
_simShow('sim-refraction');
_registerSimState('refraction', () => refrSim?.getParams(), st => refrSim?.setParams(st));
if (_embedMode) _startStateEmit('refraction');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!refrSim) {
refrSim = new RefractionSim(document.getElementById('refraction-canvas'));
@@ -7028,6 +7215,8 @@
function _openProbability() {
document.getElementById('sim-topbar-title').textContent = 'Теория вероятностей';
_simShow('sim-probability');
_registerSimState('probability', () => probSim?.getParams(), st => probSim?.setParams(st));
if (_embedMode) _startStateEmit('probability');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!probSim) {
probSim = new ProbabilitySim(document.getElementById('probability-canvas'));
@@ -7066,6 +7255,8 @@
function _openBohrAtom() {
document.getElementById('sim-topbar-title').textContent = 'Атом Бора';
_simShow('sim-bohratom');
_registerSimState('bohratom', () => bohrSim?.getParams(), st => bohrSim?.setParams(st));
if (_embedMode) _startStateEmit('bohratom');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!bohrSim) {
bohrSim = new BohrAtomSim(document.getElementById('bohratom-canvas'));
@@ -7102,6 +7293,8 @@
function _openElectrolysis() {
document.getElementById('sim-topbar-title').textContent = 'Электролиз';
_simShow('sim-electrolysis');
_registerSimState('electrolysis', () => elecSim?.getParams(), st => elecSim?.setParams(st));
if (_embedMode) _startStateEmit('electrolysis');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!elecSim) {
elecSim = new ElectrolysisSim(document.getElementById('electrolysis-canvas'));
@@ -7141,6 +7334,9 @@
document.getElementById('sim-topbar-title').textContent = 'Волны и звук';
document.getElementById('ctrl-waves').style.display = '';
_simShow('sim-waves');
_registerSimState('waves', () => wavesSim?.getParams(),
st => { if (wavesSim) { if (st.mode) wavesSim.setMode(st.mode); wavesSim.setParams(st); } });
if (_embedMode) _startStateEmit('waves');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!wavesSim) {
wavesSim = new WavesSim(document.getElementById('waves-canvas'));
@@ -7887,6 +8083,123 @@
},
};
/* ══════════════════════════════════════════════
HYDROSTATICS
══════════════════════════════════════════════ */
let hydroSim = null;
let _hydroValveOpen = true;
function _openHydro(preset) {
document.getElementById('sim-topbar-title').textContent = 'Гидростатика';
_simShow('sim-hydro');
document.getElementById('ctrl-hydro').style.display = '';
_registerSimState('hydrostatics',
() => ({ mode: hydroSim?.mode, liq: hydroSim?.liquidKey }),
st => { if (st?.mode && hydroSim) hydroMode(st.mode); });
if (_embedMode) _startStateEmit('hydrostatics');
window.addEventListener('load', () => {}, { once: true });
requestAnimationFrame(() => requestAnimationFrame(() => {
const canvas = document.getElementById('hydro-canvas');
const mode = preset || 'pressure';
if (!hydroSim) {
hydroSim = new HydroSim(canvas, mode);
hydroSim.onUpdate = _hydroUpdateUI;
} else {
hydroSim.fit();
hydroSim.play();
}
hydroMode(mode);
}));
}
function hydroMode(mode) {
if (!hydroSim) return;
hydroSim.setMode(mode);
const sel = document.getElementById('hydro-mode-sel');
if (sel) sel.value = mode;
// show/hide sub-controls
['arch','comm','surf','mat'].forEach(k => {
const el = document.getElementById('hydro-panel-' + k);
const el2 = document.getElementById('hydro-' + k + '-ctrl');
if (el) el.style.display = 'none';
if (el2) el2.style.display = 'none';
});
if (mode === 'archimedes') {
const a = document.getElementById('hydro-panel-mat');
const b = document.getElementById('hydro-arch-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'surface') {
const a = document.getElementById('hydro-panel-theta');
const b = document.getElementById('hydro-surf-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
if (mode === 'communicating') {
const a = document.getElementById('hydro-panel-comm');
const b = document.getElementById('hydro-comm-ctrl');
if (a) a.style.display = '';
if (b) b.style.display = 'flex';
}
}
function hydroToggleSurface() {
if (!hydroSim) return;
const next = hydroSim._stMode === 'capillary' ? 'drop' : 'capillary';
hydroSim._stMode = next;
const label = next === 'capillary' ? '\u041A\u0430\u043F\u0438\u043B\u043B\u044F\u0440\u044B' : '\u041A\u0430\u043F\u043B\u044F';
['hydro-surf-toggle','hydro-surf-toggle-panel'].forEach(id => {
const el = document.getElementById(id);
if (el) el.textContent = label;
});
}
function hydroToggleValve() {
if (!hydroSim) return;
_hydroValveOpen = !_hydroValveOpen;
hydroSim.setValve(_hydroValveOpen);
const label = _hydroValveOpen ? 'Кран: открыт' : 'Кран: закрыт';
const color = _hydroValveOpen ? '#06D6A0' : '#F15BB5';
['hydro-valve-btn','hydro-valve-panel-btn'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.textContent = label; el.style.color = color; el.style.borderColor = _hydroValveOpen ? 'rgba(6,214,160,.3)' : 'rgba(241,91,181,.3)'; }
});
}
function hydroSetVessels(n, btn) {
if (hydroSim) hydroSim.setNumVessels(n);
document.querySelectorAll('.hydro-nv').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function _hydroUpdateUI(info) {
if (!info) return;
const el = document.getElementById('hydro-formulas');
if (!el) return;
const lines = [];
if (info.formula) lines.push(`<span style="color:#FFD166">${info.formula}</span>`);
if (info.liqName) lines.push(`Жидкость: ${info.liqName}${info.rho ? ' (ρ=' + info.rho + ')' : ''}`);
if (info.matName) lines.push(`Материал: ${info.matName}`);
if (info.FA) lines.push(`<span style="color:#06D6E0">F_A = ${info.FA} Н</span>`);
if (info.mg) lines.push(`<span style="color:#F15BB5">mg = ${info.mg} Н</span>`);
if (info.sigma) lines.push(`σ = ${info.sigma} Н/м, θ = ${info.theta}°`);
if (info.h && !info.FA) lines.push(`h_подъём = ${info.h} мм`);
el.innerHTML = lines.join('<br>');
// result badge
const rb = document.getElementById('hydro-result');
if (rb && info.state) {
const colors = { 'ВСПЛЫВАЕТ': '#06D6A0', 'ТОНЕТ': '#F15BB5', 'ВЗВЕШЕНО': '#FFD166' };
rb.style.display = '';
rb.style.color = colors[info.state] || '#fff';
rb.style.background = (colors[info.state] || '#9B5DE5') + '18';
rb.style.border = '1px solid ' + (colors[info.state] || '#9B5DE5') + '44';
rb.textContent = info.state;
} else if (rb) {
rb.style.display = 'none';
}
}
let _theoryOpen = false;
function toggleTheory() {
_theoryOpen = !_theoryOpen;
@@ -7923,6 +8236,51 @@
const _embedMode = _qp.get('embed') === '1';
const _autoSim = _qp.get('sim');
/* ── Sim state relay (embed mode only) ──────────────────────────────── */
// Map simId → { getState, applyState } registered by openSim handlers
const _simStateRegistry = {};
function _registerSimState(simId, getState, applyState) {
_simStateRegistry[simId] = { getState, applyState };
}
let _lastEmittedState = null;
let _stateEmitInterval = null;
function _startStateEmit(simId) {
if (_stateEmitInterval) clearInterval(_stateEmitInterval);
_lastEmittedState = null;
_stateEmitInterval = setInterval(() => {
const reg = _simStateRegistry[simId];
if (!reg) return;
try {
const state = reg.getState();
const json = JSON.stringify(state);
if (json === _lastEmittedState) return;
_lastEmittedState = json;
window.parent.postMessage({ type: 'sim_state', simId, state }, '*');
} catch {}
}, 400);
}
function _stopStateEmit() {
if (_stateEmitInterval) { clearInterval(_stateEmitInterval); _stateEmitInterval = null; }
_lastEmittedState = null;
}
// Receive apply_sim_state from parent (students)
window.addEventListener('message', e => {
if (!_embedMode) return;
const d = e.data;
if (!d || d.type !== 'apply_sim_state') return;
const reg = _simStateRegistry[_autoSim];
if (!reg) return;
try {
reg.applyState(d.state);
_lastEmittedState = JSON.stringify(d.state); // suppress echo
} catch {}
});
if (_embedMode) {
document.querySelector('.sidebar').style.display = 'none';
document.querySelector('.sb-content').style.marginLeft = '0';
@@ -7932,7 +8290,8 @@
if (_autoSim) {
document.getElementById('lab-sim').classList.add('open');
document.querySelector('.sim-topbar').style.display = 'none';
openSim(_autoSim);
// defer until all external scripts are loaded
window.addEventListener('load', () => openSim(_autoSim));
}
} else {
/* init — fetch sim settings + permissions in parallel, then render */
@@ -8006,5 +8365,6 @@
<script src="/js/labs/probability.js"></script>
<script src="/js/labs/bohratom.js"></script>
<script src="/js/labs/electrolysis.js"></script>
<script src="/js/labs/hydrostatics.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -787,6 +787,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -211,6 +211,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+178 -47
View File
@@ -8,6 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
@@ -135,10 +136,52 @@
/* question pick cards */
.lq-q-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
/* question filters */
.lq-filter-row { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
.lq-filter-select {
flex: 1; padding: 8px 10px;
border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 500;
color: #0F172A; background: #fff; outline: none; cursor: pointer;
transition: border-color 0.15s; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238898AA' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
.lq-filter-select:focus { border-color: var(--violet); }
.lq-q-count { font-size: 0.7rem; color: #8898AA; font-weight: 600; white-space: nowrap; margin-bottom: 12px; }
/* load more */
.btn-load-more {
width: 100%; padding: 10px; border: 1.5px dashed rgba(155,93,229,0.3);
border-radius: 12px; background: transparent; margin-bottom: 24px;
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
color: var(--violet); cursor: pointer; transition: all 0.15s;
}
.btn-load-more:hover { background: rgba(155,93,229,0.05); border-color: var(--violet); }
.btn-load-more:disabled { opacity: 0.5; cursor: default; }
/* result stat cards */
.lq-result-stats { display: flex; gap: 8px; margin-bottom: 14px; }
.lq-result-stat { flex: 1; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,0.04); text-align: center; }
.lq-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 900; color: #0F172A; }
.lq-result-stat-lbl { font-size: 0.64rem; color: #8898AA; margin-top: 2px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.lq-result-stat.rs-correct { background: rgba(6,214,160,0.08); }
.lq-result-stat.rs-correct .lq-result-stat-val { color: #059652; }
.lq-result-stat.rs-wrong { background: rgba(239,71,111,0.07); }
.lq-result-stat.rs-wrong .lq-result-stat-val { color: #EF476F; }
/* explanation box */
.lq-explanation {
margin-top: 14px; padding: 13px 15px;
background: rgba(6,214,224,0.06); border: 1.5px solid rgba(6,214,224,0.2);
border-radius: 12px; font-size: 0.82rem; color: #0F172A; line-height: 1.6;
}
.lq-explanation-label { font-size: 0.64rem; font-weight: 700; color: #0891B2; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.lq-q-card {
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
border-radius: 14px; padding: 14px 16px;
display: flex; align-items: center; gap: 12px;
display: flex; align-items: flex-start; gap: 12px;
box-shadow: 0 1px 4px rgba(15,23,42,0.04); transition: all 0.15s;
}
.lq-q-card:hover { border-color: rgba(155,93,229,0.25); }
@@ -146,9 +189,10 @@
.lq-q-body { flex: 1; min-width: 0; }
.lq-q-text {
font-size: 0.84rem; font-weight: 600; color: #0F172A;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; line-height: 1.5; min-height: 1.5em;
}
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; }
.lq-q-meta { font-size: 0.7rem; color: #8898AA; margin-top: 5px; display: flex; gap: 8px; flex-wrap: wrap; }
.btn-launch {
padding: 7px 16px; border: none; border-radius: 999px;
background: var(--grad-1); color: #fff;
@@ -228,7 +272,7 @@
}
.lq-results-title {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: #0F172A; margin-bottom: 14px; display: flex; align-items: center; gap: 8px;
color: #0F172A; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
}
.result-bars { display: flex; flex-direction: column; gap: 10px; }
.result-bar-row { display: flex; align-items: center; gap: 10px; }
@@ -313,6 +357,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -421,12 +466,27 @@
<i data-lucide="search" class="lq-search-icon"></i>
<input class="lq-search" id="q-search" type="text" placeholder="Поиск вопросов…" />
</div>
<div class="lq-filter-row">
<select class="lq-filter-select" id="topic-filter" onchange="onTopicFilter()">
<option value="">Все темы</option>
</select>
<select class="lq-filter-select" id="diff-filter" onchange="onDiffFilter()" style="max-width:130px">
<option value="">Любой уровень</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
</div>
<div class="lq-q-count" id="q-count" style="display:none"></div>
<div class="lq-q-list" id="q-list">
<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">
<div class="spinner" style="margin:0 auto 10px"></div>
Загрузка вопросов…
</div>
</div>
<button class="btn-load-more" id="btn-load-more" style="display:none" onclick="loadMoreQuestions()">
Загрузить ещё
</button>
</div>
</div>
@@ -435,6 +495,8 @@
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -461,6 +523,22 @@
'fill-blank': 'Заполни пробел', ordering: 'Порядок',
};
/* ── math rendering ── */
const MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
@@ -513,8 +591,12 @@
let answerCount = 0;
let sseSource = null;
let allQuestions = [];
let filteredQuestions = [];
let searchTimeout = null;
let _topicFilter = '';
let _diffFilter = '';
let _qPage = 0;
let _totalQ = 0;
const Q_LIMIT = 30;
/* ── load classes ── */
async function loadClasses() {
@@ -593,9 +675,24 @@
document.getElementById('session-header-label').innerHTML =
'<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg> Активна · ' + selectedClass.name;
updateStudentCounter(0);
loadTopics();
lucide.createIcons();
}
async function loadTopics() {
try {
const topics = await LS.api('/api/topics');
const sel = document.getElementById('topic-filter');
sel.innerHTML = '<option value="">Все темы</option>';
(topics || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.name;
sel.appendChild(opt);
});
} catch {}
}
function updateStudentCounter(count) {
answerCount = count;
document.getElementById('as-students-text').textContent =
@@ -658,34 +755,56 @@
}
/* ── load questions ── */
async function loadQuestions() {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
async function loadQuestions(reset = true) {
if (reset) { _qPage = 0; allQuestions = []; }
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA"><div class="spinner" style="margin:0 auto 10px"></div> Загрузка…</div>';
const params = new URLSearchParams({ limit: Q_LIMIT, offset: _qPage * Q_LIMIT });
if (_topicFilter) params.set('topic_id', _topicFilter);
if (_diffFilter) params.set('difficulty', _diffFilter);
const sq = document.getElementById('q-search')?.value.trim();
if (sq) params.set('search', sq);
try {
const data = await LS.api('/api/questions?limit=50');
allQuestions = data.rows || [];
filteredQuestions = allQuestions;
const data = await LS.api('/api/questions?' + params.toString());
const rows = data.rows || [];
_totalQ = data.total ?? (reset ? rows.length : allQuestions.length + rows.length);
allQuestions = reset ? rows : [...allQuestions, ...rows];
renderQuestionList();
const btnMore = document.getElementById('btn-load-more');
const countEl = document.getElementById('q-count');
if (btnMore) btnMore.style.display = allQuestions.length < _totalQ ? '' : 'none';
if (countEl) { countEl.textContent = `Показано ${allQuestions.length} из ${_totalQ}`; countEl.style.display = ''; }
} catch {
document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
if (reset) document.getElementById('q-list').innerHTML = '<div style="padding:20px;text-align:center;color:#8898AA;font-size:0.82rem">Ошибка загрузки вопросов</div>';
}
}
async function loadMoreQuestions() {
const btn = document.getElementById('btn-load-more');
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
_qPage++;
await loadQuestions(false);
if (btn) { btn.disabled = false; btn.textContent = 'Загрузить ещё'; }
}
function onTopicFilter() { _topicFilter = document.getElementById('topic-filter').value; loadQuestions(true); }
function onDiffFilter() { _diffFilter = document.getElementById('diff-filter').value; loadQuestions(true); }
function renderQuestionList() {
const list = document.getElementById('q-list');
if (!filteredQuestions.length) {
if (!allQuestions.length) {
list.innerHTML = '<div style="padding:30px;text-align:center;color:#8898AA;font-size:0.82rem">Вопросов не найдено</div>';
lucide.createIcons();
return;
}
let html = '';
filteredQuestions.forEach(q => {
allQuestions.forEach(q => {
const diffCls = 'badge-diff-' + (q.difficulty || 1);
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 2 ? 'Средний' : 'Сложный';
const typeLabel = TYPE_LABELS[q.type] || q.type || '';
const isLaunched = currentQuestion && currentQuestion.id === q.id;
html += `<div class="lq-q-card${isLaunched ? ' launched' : ''}">
<div class="lq-q-body">
<div class="lq-q-text">${esc(q.text)}</div>
<div class="lq-q-text" data-text="${esc(q.text)}"></div>
<div class="lq-q-meta">
<span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
@@ -698,35 +817,28 @@
</div>`;
});
list.innerHTML = html;
list.querySelectorAll('.lq-q-text[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
}
/* search questions */
/* search questions — server-side */
document.getElementById('q-search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = document.getElementById('q-search').value.trim().toLowerCase();
filteredQuestions = q
? allQuestions.filter(item => item.text.toLowerCase().includes(q) || (item.topic || '').toLowerCase().includes(q))
: allQuestions;
renderQuestionList();
}, 280);
searchTimeout = setTimeout(() => loadQuestions(true), 350);
});
/* ── launch question ── */
async function launchQuestion(questionId) {
if (!activeSession) return;
const q = allQuestions.find(x => x.id === questionId);
if (!q) return;
try {
await LS.api('/api/live/' + activeSession.id + '/question', {
const resp = await LS.api('/api/live/' + activeSession.id + '/question', {
method: 'PUT',
body: JSON.stringify({ question_id: questionId }),
});
currentQuestion = q;
currentQuestion = resp.question || allQuestions.find(x => x.id === questionId) || { id: questionId };
answerCount = 0;
updateStudentCounter(0);
renderActiveQuestion(q);
renderActiveQuestion(currentQuestion);
renderQuestionList();
} catch (e) {
LS.toast(e.message || 'Ошибка запуска вопроса', 'error');
@@ -750,7 +862,7 @@
const letter = String.fromCharCode(65 + idx);
optionsHtml += `<div class="lq-active-opt${opt.is_correct ? ' correct' : ''}">
<div class="lq-opt-letter">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
<span data-text="${esc(opt.text)}"></span>
</div>`;
});
optionsHtml += '</div>';
@@ -762,7 +874,7 @@
В эфире · <span class="badge ${diffCls}">${diffLabel}</span>
${typeLabel ? `<span class="badge badge-type">${esc(typeLabel)}</span>` : ''}
</div>
<div class="lq-active-text">${esc(q.text)}</div>
<div class="lq-active-text" data-text="${esc(q.text)}"></div>
${optionsHtml}
<div class="lq-counter">
<div>
@@ -779,6 +891,7 @@
</button>
<div id="results-area"></div>
`;
card.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -798,38 +911,48 @@
}
function renderResults(data, container) {
const { answers = [], options = [] } = data;
// count answers per option
const countMap = {};
answers.forEach(a => {
const key = a.option_id ?? a.answer ?? 'other';
countMap[key] = (countMap[key] || 0) + 1;
});
const totalAnswers = answers.length;
const maxCount = Math.max(...Object.values(countMap), 1);
// use options from current question if not in results
const opts = options.length ? options : (currentQuestion?.options || []);
const opts = data.options || [];
const q = data.question || {};
const stats = data.stats || {};
const total = stats.total || 0;
const correct= stats.correct|| 0;
const maxCount = Math.max(...opts.map(o => o.chosen_count || 0), 1);
if (!opts.length) {
container.innerHTML = '<div style="padding:16px;text-align:center;color:#8898AA;font-size:0.84rem">Нет данных о вариантах ответа</div>';
return;
}
const pctCorrect = total > 0 ? Math.round(correct / total * 100) : 0;
const pctWrong = total > 0 ? 100 - pctCorrect : 0;
let html = `<div class="lq-results-wrap">
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты · ${totalAnswers} ответов</div>
<div class="lq-results-title"><i data-lucide="bar-chart-horizontal" style="width:14px;height:14px;opacity:0.5"></i> Результаты</div>
<div class="lq-result-stats">
<div class="lq-result-stat">
<div class="lq-result-stat-val">${total}</div>
<div class="lq-result-stat-lbl">Ответов</div>
</div>
<div class="lq-result-stat rs-correct">
<div class="lq-result-stat-val">${pctCorrect}%</div>
<div class="lq-result-stat-lbl">Верно</div>
</div>
<div class="lq-result-stat rs-wrong">
<div class="lq-result-stat-val">${pctWrong}%</div>
<div class="lq-result-stat-lbl">Неверно</div>
</div>
</div>
<div class="result-bars">`;
opts.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
const key = opt.id ?? idx;
const count = countMap[key] || 0;
const pct = Math.round((count / Math.max(maxCount, 1)) * 100);
const count = opt.chosen_count || 0;
const pct = Math.round(count / maxCount * 100);
const isCorrect = opt.is_correct;
html += `<div class="result-bar-row">
<div class="result-bar-label${isCorrect ? ' correct-lbl' : ''}">
${isCorrect ? '<span class="rb-correct-marker"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>' : letter + '.'}
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px">${esc(opt.text)}</span>
<span class="rb-opt-text" data-text="${esc(opt.text)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100px"></span>
</div>
<div class="result-bar-track">
<div class="result-bar-fill${isCorrect ? ' correct-fill' : ''}" style="width:${pct}%"></div>
@@ -838,8 +961,16 @@
</div>`;
});
html += '</div></div>';
html += '</div>';
if (q.explanation) {
html += `<div class="lq-explanation">
<div class="lq-explanation-label">Объяснение</div>
<div class="lq-exp-text" data-text="${esc(q.explanation)}"></div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = mathHtml(el.dataset.text); });
lucide.createIcons();
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -351,6 +351,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+1
View File
@@ -550,6 +550,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+24 -4
View File
@@ -8,6 +8,7 @@
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; overflow: hidden; display: flex; flex-direction: column; }
@@ -243,6 +244,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link nav-active"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
@@ -382,6 +384,8 @@
</div>
<script src="/js/api.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
if (!LS.requireAuth()) throw new Error();
@@ -409,6 +413,22 @@
};
const SUBJECT_NAMES = { bio: 'Биология', chem: 'Химия', math: 'Математика', phys: 'Физика', other: 'Другое' };
/* ── math rendering ── */
const _MATH_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtml(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _MATH_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
@@ -570,7 +590,7 @@
<span class="badge ${diffCls}">${diffLabel}</span>
<span class="badge badge-type">${esc(typeLabel)}</span>
</div>
<div class="qc-text">${esc(q.text)}</div>
<div class="qc-text">${mathHtml(q.text)}</div>
<div class="qc-footer">
<span class="qc-topic"><i data-lucide="tag" style="width:11px;height:11px"></i> ${esc(q.topic || SUBJECT_NAMES[q.subject_slug] || '—')}</span>
${optsCount ? `<span class="qc-opts-count"><i data-lucide="list" style="width:11px;height:11px"></i> ${optsCount} вар.</span>` : ''}
@@ -578,20 +598,20 @@
if (isExpanded) {
html += `<div class="qc-preview">
<div class="qc-preview-text">${esc(q.text)}</div>`;
<div class="qc-preview-text">${mathHtml(q.text)}</div>`;
if ((q.options || []).length) {
html += '<div class="qc-options">';
q.options.forEach((opt, idx) => {
const letter = String.fromCharCode(65 + idx);
html += `<div class="qc-option${opt.is_correct ? ' correct' : ''}">
<div class="qc-option-marker">${opt.is_correct ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' : letter}</div>
<span>${esc(opt.text)}</span>
<span>${mathHtml(opt.text)}</span>
</div>`;
});
html += '</div>';
}
if (q.explanation) {
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${esc(q.explanation)}</div>`;
html += `<div class="qc-explanation"><strong>Пояснение:</strong> ${mathHtml(q.explanation)}</div>`;
}
html += '</div>';
}
+1
View File
@@ -167,6 +167,7 @@
<div class="bio-topbar">
<a href="/red-book.html" style="color:var(--rb-muted);text-decoration:none;font-size:12px;border:1px solid var(--rb-border);padding:6px 12px;border-radius:8px;"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M17 14 12 3 7 14"/><path d="M4 20 8 11h8l4 9"/><line x1="12" y1="20" x2="12" y2="22"/></svg> Биомы Беларуси</h1>
<div class="biome-tabs" id="biome-tabs"></div>
<button id="sound-btn" onclick="toggleSound()" title="Звуки биома" style="display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;cursor:pointer;flex-shrink:0;transition:all .2s;"><svg class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg> Звук</button>
+1
View File
@@ -170,6 +170,7 @@
<div class="eco-topbar">
<a href="/red-book.html" class="eco-back"><svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> Красная книга</a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<h1><svg class="ic" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg> Пищевые сети · Симулятор экосистем</h1>
<span style="margin-left:auto;font-size:12px;color:var(--rb-muted)" id="node-count"></span>
<button onclick="toggleEnergyFlow()" id="btn-energy" title="Анимация потока энергии" style="background:transparent;border:1px solid var(--rb-border);color:var(--rb-muted);font-size:12px;padding:6px 12px;border-radius:8px;cursor:pointer;transition:all .2s"><svg class="ic" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg> Поток</button>
+1
View File
@@ -197,6 +197,7 @@
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
+1
View File
@@ -517,6 +517,7 @@
<p class="rb-sb-section">РАЗДЕЛЫ</p>
<a href="/red-book.html" class="sb-link active"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Каталог видов</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<a href="/collection-rb.html" class="sb-link"><i data-lucide="star" class="sb-icon"></i><span class="sb-lbl">Моя коллекция</span></a>
<a href="/red-book-ecosystem.html" class="sb-link"><i data-lucide="git-fork" class="sb-icon"></i><span class="sb-lbl">Пищевые сети</span></a>
<a href="/red-book-biomes.html" class="sb-link"><i data-lucide="trees" class="sb-icon"></i><span class="sb-lbl">Биомы</span></a>
+1
View File
@@ -305,6 +305,7 @@
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<a href="/lesson-history" class="sb-link"><i data-lucide="archive" class="sb-icon"></i><span class="sb-lbl">Архив уроков</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
+52 -3
View File
@@ -672,6 +672,8 @@ window.LS = {
parentGetLinks, parentCreateLink, parentUpdateLink, parentDeleteLink,
crCreateSession, crGetSession, crEndSession, crGetActiveByClass, crGetMyActive,
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
crAdminGetAllHistory, crAdminGetTeachersList,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
initPage,
@@ -985,6 +987,28 @@ async function crGetChat(id) { return req('GET', `/classroom/
async function crGetAttendance(id) { return req('GET', `/classroom/${id}/attendance`); }
async function crSignal(id, targetUserId, payload) { return req('POST', `/classroom/${id}/signal`, { target_user_id: targetUserId, payload }); }
async function crGetOnlineStudents() { return req('GET', '/classroom/online-students'); }
async function crGetMyHistory(page = 1) { return req('GET', `/classroom/my/history?page=${page}`); }
async function crGetClassHistory(classId, page = 1, search = '') {
const q = search ? `&search=${encodeURIComponent(search)}` : '';
return req('GET', `/classroom/class/${classId}/history?page=${page}${q}`);
}
async function crGetSessionSummary(id) { return req('GET', `/classroom/${id}/summary`); }
async function crExportChatUrl(id) { return `/api/classroom/${id}/chat/export`; }
async function crGetAllNotes(id) { return req('GET', `/classroom/${id}/notes/all`); }
async function crDeleteHistory(id) { return req('DELETE', `/classroom/${id}/history`); }
async function crAdminGetAllHistory(p = {}) {
const q = new URLSearchParams();
if (p.page) q.set('page', p.page);
if (p.limit) q.set('limit', p.limit);
if (p.search) q.set('search', p.search);
if (p.teacher) q.set('teacher', p.teacher);
if (p.class_id) q.set('class_id', p.class_id);
if (p.date_from) q.set('date_from', p.date_from);
if (p.date_to) q.set('date_to', p.date_to);
if (p.sort) q.set('sort', p.sort);
return req('GET', `/classroom/admin/sessions?${q}`);
}
async function crAdminGetTeachersList() { return req('GET', '/classroom/admin/teachers-list'); }
/* ── gamification admin ────────────────────────────────────────────────── */
async function adminGamAward(data) { return req('POST', '/gamification/admin/award', data); }
@@ -1046,6 +1070,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
styleEl.textContent = STYLE;
document.addEventListener('DOMContentLoaded', () => {
if (window._lsLiveOverriddenByClassroom) return;
document.head.appendChild(styleEl);
document.body.appendChild(el);
});
@@ -1053,15 +1078,38 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
let currentLiveId = null;
let answered = false;
/* render text that may contain \(...\) or \[...\] LaTeX using window.katex */
function _mathHtml(text) {
if (!text) return '';
const kat = window.katex;
if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; }
let out = '', i = 0;
while (i < text.length) {
const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i);
let next = -1, close = '', disp = false;
if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; }
else if (bi >= 0) { next = bi; close = '\\]'; disp = true; }
const plain = document.createElement('span');
if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; }
plain.textContent = text.slice(i, next); out += plain.innerHTML;
const ci = text.indexOf(close, next + 2);
if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; }
try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); }
catch { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; }
i = ci + close.length;
}
return out;
}
function openOverlay(liveId, question, options) {
currentLiveId = liveId;
answered = false;
document.getElementById('lslq-text').textContent = question.text;
document.getElementById('lslq-text').innerHTML = _mathHtml(question.text);
const keys = 'АБВГДЕ';
document.getElementById('lslq-opts').innerHTML = (options || []).map((o, i) => `
<div class="ls-live-opt" data-id="${o.id}" onclick="window._lsLiveAnswer(${liveId},${o.id},this)">
<span class="ls-live-opt-key">${keys[i] || i+1}</span>
<span>${esc(o.text)}</span>
<span>${_mathHtml(o.text)}</span>
</div>`).join('');
document.getElementById('lslq-status').textContent = 'Выберите ответ';
document.getElementById('ls-live-overlay').classList.add('open');
@@ -1079,7 +1127,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
return `<div class="ls-live-opt ${cls}" style="flex-direction:column;align-items:flex-start;gap:4px">
<div style="display:flex;align-items:center;gap:10px;width:100%">
<span class="ls-live-opt-key">${keys[i]||i+1}</span>
<span style="flex:1">${esc(o.text)}</span>
<span style="flex:1">${_mathHtml(o.text)}</span>
<span class="ls-live-result-pct">${pct}%</span>
</div>
<div class="ls-live-result-bar" style="width:100%">
@@ -1103,6 +1151,7 @@ async function adminGamGetUser(id) { return req('GET', `/gamifi
};
document.addEventListener('DOMContentLoaded', () => {
if (window._lsLiveOverriddenByClassroom) return;
connectSSE(d => {
if (d.type === 'live_question') {
openOverlay(d.liveId, d.question, d.question?.options);