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);