feat: textbooks — модуль учебников + чтение как ДЗ (3 фазы)

Фаза 1 — структура и каталог:
  - frontend/textbooks/chemistry_9.html (Шиманович, 60 §) + physics_9.html (Исаченкова, 38 §)
  - frontend/textbooks.html — каталог в стиле LearnSpace (карточки с обложками)
  - Маршруты: /textbooks (каталог), /textbook/<slug> (полноэкранный учебник)
  - Сайдбар: пункт «Учебники» (book-open-text)
  - Feature flag feature_textbooks_enabled, hideDisabledFeatures map

Фаза 2 — прогресс в localStorage + UI чтения:
  - frontend/js/textbook-tracker.js — инжектится в каждый учебник:
    - «← Учебники» overlay-кнопка (top-left, semi-transparent)
    - «Прочитано» чекбокс рядом с каждым §-заголовком
    - Зелёный dot на pill уже прочитанных параграфов
    - Авто-открытие последнего параграфа при возврате
  - Каталог показывает прогресс-бар «X из Y прочитано» + кнопку «Продолжить»

Фаза 3 — серверный прогресс + назначение чтения как ДЗ:
  - Таблица textbooks (slug, subject, grade, title, author, color, ...)
  - Таблица textbook_progress (user_id, textbook_id, JSON read[], last_para)
  - Колонки assignments.textbook_id + textbook_paragraphs
  - API: GET /api/textbooks (с прогрессом), GET /:slug, POST /:slug/progress,
    GET /:slug/class-progress (учитель)
  - tracker.js синхронизирует прогресс через POST /progress (если залогинен)
  - На каталоге у учителей кнопка «Назначить чтение» — модалка с выбором
    классов + параграфы («1-5» или «1,3,5») + deadline
  - bulkCreateAssignment расширен: принимает textbook_slug, резолвит в id

Миграция 004 идемпотентная; сиды двух учебников включены.
This commit is contained in:
Maxim Dolgolyov
2026-05-16 14:05:19 +03:00
parent 31a51956b6
commit e8018d85c1
10 changed files with 23974 additions and 4 deletions
@@ -595,7 +595,8 @@ function deleteTemplate(req, res) {
/* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
function bulkCreateAssignment(req, res) {
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id,
is_homework = 0, textbook_slug, textbook_paragraphs } = req.body;
let { subject_slug } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length)
@@ -608,6 +609,16 @@ function bulkCreateAssignment(req, res) {
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
// Textbook: resolve slug → id, derive subject from textbook
let textbook_id = null;
if (textbook_slug) {
const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug);
if (!tb) return res.status(400).json({ error: 'Учебник не найден' });
textbook_id = tb.id;
if (!subject_slug) subject_slug = tb.subject;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
@@ -619,9 +630,9 @@ function bulkCreateAssignment(req, res) {
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0);
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0, textbook_id, textbook_paragraphs || null);
ids.push(r.lastInsertRowid);
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(cls.id);
@@ -0,0 +1,42 @@
-- Feature flag for textbooks module
INSERT OR IGNORE INTO app_settings (key, value) VALUES ('feature_textbooks_enabled', '1');
-- Catalog of textbooks (admin-editable; html_path is relative to /frontend/textbooks/)
CREATE TABLE textbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
subject TEXT NOT NULL, -- 'chemistry', 'physics', 'math', 'biology', ...
grade INTEGER NOT NULL, -- 9, 10, 11, ...
title TEXT NOT NULL,
author TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
html_path TEXT NOT NULL, -- relative filename: 'chemistry_9.html'
para_count INTEGER NOT NULL DEFAULT 0, -- total paragraphs (for progress %)
color TEXT NOT NULL DEFAULT 'violet',-- visual theme: 'amber','blue','violet','green',...
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Per-user reading progress (one row per (user, textbook))
CREATE TABLE textbook_progress (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
textbook_id INTEGER NOT NULL REFERENCES textbooks(id) ON DELETE CASCADE,
paragraphs_read TEXT NOT NULL DEFAULT '[]', -- JSON array of para keys like ["p1","p3","p7"]
last_para TEXT, -- last opened paragraph key (e.g. 'p15')
last_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, textbook_id)
);
-- Assignment extension: link to textbook + paragraph range string ("1-5" or "1,3,5")
ALTER TABLE assignments ADD COLUMN textbook_id INTEGER REFERENCES textbooks(id) ON DELETE SET NULL;
ALTER TABLE assignments ADD COLUMN textbook_paragraphs TEXT;
-- Seed: chemistry 9 + physics 9 (the two files we just copied)
INSERT INTO textbooks (slug, subject, grade, title, author, description, html_path, para_count, color, sort_order) VALUES
('chemistry-9', 'chemistry', 9, 'Химия — 9 класс', 'Шиманович Е. Я.',
'Полный курс химии за 9 класс. §1–60: строение атома, химическая связь, классы соединений, ОВР, металлы и их соединения, электролиз.',
'chemistry_9.html', 60, 'amber', 1),
('physics-9', 'physics', 9, 'Физика — 9 класс', 'Исаченкова Л. А.',
'Полный курс физики за 9 класс: §1–38. Механика, кинематика, динамика, статика, законы сохранения, импульс, работа и энергия.',
'physics_9.html', 38, 'blue', 2);
+109
View File
@@ -0,0 +1,109 @@
'use strict';
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
router.use(authMiddleware);
/* GET /api/textbooks — list with current user's progress */
router.get('/', (req, res) => {
const rows = db.prepare(`
SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description,
t.html_path, t.para_count, t.color, t.sort_order
FROM textbooks t
WHERE t.is_active = 1
ORDER BY t.sort_order, t.subject, t.grade
`).all();
const myProgress = db.prepare(`
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=?
`).all(req.user.id);
const progressMap = {};
for (const p of myProgress) {
let arr = [];
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
progressMap[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
}
res.json({
textbooks: rows.map(t => ({
...t,
progress: progressMap[t.id] || { read: [], last_para: null, last_at: null },
})),
});
});
/* GET /api/textbooks/:slug — single textbook detail */
router.get('/:slug', (req, res) => {
const t = db.prepare('SELECT * FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
const p = db.prepare('SELECT paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id);
let read = [];
if (p) { try { read = JSON.parse(p.paragraphs_read || '[]'); } catch {} }
res.json({ ...t, progress: { read, last_para: p?.last_para || null, last_at: p?.last_at || null } });
});
/* POST /api/textbooks/:slug/progress — update progress
body: { last_para?: 'p15', mark_read?: 'p15', mark_unread?: 'p15' } */
router.post('/:slug/progress', (req, res) => {
const t = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
const { last_para, mark_read, mark_unread } = req.body || {};
// Atomic upsert
const existing = db.prepare('SELECT paragraphs_read FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id);
let arr = [];
if (existing) { try { arr = JSON.parse(existing.paragraphs_read || '[]'); } catch {} }
if (mark_read && typeof mark_read === 'string' && !arr.includes(mark_read)) arr.push(mark_read);
if (mark_unread && typeof mark_unread === 'string') arr = arr.filter(p => p !== mark_unread);
db.prepare(`
INSERT INTO textbook_progress (user_id, textbook_id, paragraphs_read, last_para, last_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, textbook_id) DO UPDATE SET
paragraphs_read = excluded.paragraphs_read,
last_para = COALESCE(excluded.last_para, textbook_progress.last_para),
last_at = excluded.last_at
`).run(req.user.id, t.id, JSON.stringify(arr), last_para || null);
res.json({ ok: true, read: arr });
});
/* GET /api/textbooks/:slug/class-progress — teacher view: progress of all students in class
query: ?class_id=N */
router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => {
const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug);
if (!t) return res.status(404).json({ error: 'Учебник не найден' });
const classId = Number(req.query.class_id);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
if (req.user.role === 'teacher') {
const own = db.prepare('SELECT 1 FROM classes WHERE id=? AND teacher_id=?').get(classId, req.user.id);
if (!own) return res.status(403).json({ error: 'Нет доступа к классу' });
}
const rows = db.prepare(`
SELECT u.id AS user_id, u.name,
COALESCE(tp.paragraphs_read, '[]') AS paragraphs_read,
tp.last_para, tp.last_at
FROM class_members cm
JOIN users u ON u.id = cm.user_id
LEFT JOIN textbook_progress tp ON tp.user_id = u.id AND tp.textbook_id = ?
WHERE cm.class_id = ?
ORDER BY u.name
`).all(t.id, classId);
res.json({
students: rows.map(r => {
let read = [];
try { read = JSON.parse(r.paragraphs_read); } catch {}
return { user_id: r.user_id, name: r.name, read_count: read.length, last_para: r.last_para, last_at: r.last_at };
}),
});
});
module.exports = router;
+19
View File
@@ -51,6 +51,7 @@ const collectionRoutes = require('./routes/collection');
const redBookRoutes = require('./routes/red-book');
const parentRoutes = require('./routes/parent');
const exam9Routes = require('./routes/exam9');
const textbookRoutes = require('./routes/textbooks');
const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -168,6 +169,7 @@ app.use('/api/red-book', redBookRoutes);
app.use('/api/biochem', require('./routes/biochem'));
app.use('/api/parent', parentRoutes);
app.use('/api/exam9', exam9Routes);
app.use('/api/textbooks', textbookRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');
@@ -318,6 +320,23 @@ app.use((req, res, next) => {
next();
});
// Clean URL for textbooks: /textbook/<slug> → frontend/textbooks/<html_path>
const _textbookDb = require('./db/db');
const _stmtTextbookPath = _textbookDb.prepare('SELECT html_path FROM textbooks WHERE slug=? AND is_active=1');
app.get('/textbook/:slug', (req, res, next) => {
const row = _stmtTextbookPath.get(req.params.slug);
if (!row) return next();
const filePath = path.join(frontendDir, 'textbooks', row.html_path);
if (!isProd) res.setHeader('Cache-Control', 'no-store');
res.sendFile(filePath, err => { if (err) next(); });
});
// Catalog: /textbooks → frontend/textbooks.html (explicit to avoid conflict with /textbooks/ directory)
app.get('/textbooks', (_req, res) => {
if (!isProd) res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(frontendDir, 'textbooks.html'));
});
// Serve HTML files without extension (/dashboard → dashboard.html)
// In dev: disable cache so edits are always picked up immediately
const htmlCacheOpts = isProd ? { extensions: ['html'] } : {