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:
@@ -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;
|
||||
Reference in New Issue
Block a user