feat(catalog): хаб-страница для Алгебры 8 (3 главы под единым слагом)

- migration 014: parent_slug column + algebra-8 hub row +
  rename old algebra-8 → algebra-8-ch1 (progress сохраняется
  через стабильный textbook_id=3)
- backend/routes/textbooks.js: GET / фильтрует parent_slug IS NULL;
  aggregated progress для хабов; новый GET /:slug/children
- algebra_8_hub.html: новая хаб-страница с 3 карточками глав,
  hero с общим прогрессом, XP-бейдж, ссылки на главы
- algebra_8/ch2/ch3: кнопки cross-chapter заменены на
  одну «К алгебре 8» в шапке

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-27 16:49:20 +03:00
parent 033c941b02
commit 699fdcc7fb
6 changed files with 469 additions and 24 deletions
@@ -0,0 +1,23 @@
-- Algebra 8 hub migration.
-- Adds parent_slug column so chapters can be grouped under a hub textbook.
-- The original algebra-8 row (id=3) becomes algebra-8-ch1; its id never changes
-- so all textbook_progress rows keep their FK references intact.
-- 1. Add nullable parent_slug column (idempotent: will fail gracefully if already exists,
-- but migrations-runner wraps each file in a transaction so this is fine as a fresh run).
ALTER TABLE textbooks ADD COLUMN parent_slug TEXT;
-- 2. Rename the existing chapter-1 slug from 'algebra-8' to 'algebra-8-ch1'.
-- Row id=3 is untouched, so textbook_progress.textbook_id=3 continues to resolve correctly.
UPDATE textbooks SET slug = 'algebra-8-ch1' WHERE slug = 'algebra-8';
-- 3. Insert the new hub row.
INSERT INTO textbooks
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active)
VALUES
('algebra-8', 'math', 8, 'Алгебра — 8 класс', '',
'Полный курс алгебры 8 класса: квадратные корни и действительные числа, квадратные уравнения, неравенства с одной переменной. 3 главы, 21 параграф + 3 финала, 100+ интерактивов, 21 босс-проверка.',
'algebra_8_hub.html', 21, 'pink', 3, 1);
-- 4. Tag all three chapter rows as children of the hub.
UPDATE textbooks SET parent_slug = 'algebra-8' WHERE slug IN ('algebra-8-ch1', 'algebra-8-ch2', 'algebra-8-ch3');
+84 -5
View File
@@ -59,24 +59,66 @@ function checkAssignmentCompletion(userId, textbookId, readSet) {
LITERAL ROUTES FIRST — must come before /:slug
════════════════════════════════════════════════ */
/* GET /api/textbooks — list with current user's progress */
/* GET /api/textbooks — catalog list (top-level only) with current user's progress.
Rows with a parent_slug are chapters hidden from the catalog but still directly accessible. */
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
FROM textbooks t WHERE t.is_active = 1 AND t.parent_slug IS NULL
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 map = {};
const progressById = {};
for (const p of myProgress) {
let arr = [];
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
map[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
progressById[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
}
res.json({ textbooks: rows.map(t => ({ ...t, progress: map[t.id] || { read: [], last_para: null, last_at: null } })) });
/* Prepared statements reused for each hub row that has children. */
const stmtChildren = db.prepare(`
SELECT id FROM textbooks WHERE parent_slug = ? AND is_active = 1
`);
const stmtChildProgress = db.prepare(`
SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress
WHERE user_id = ? AND textbook_id IN (SELECT id FROM textbooks WHERE parent_slug = ?)
ORDER BY last_at DESC
`);
const textbooks = rows.map(t => {
const children = stmtChildren.all(t.slug);
if (children.length === 0) {
return { ...t, progress: progressById[t.id] || { read: [], last_para: null, last_at: null } };
}
/* Hub: aggregate progress across all child chapters. */
const childRows = stmtChildProgress.all(req.user.id, t.slug);
const readSet = new Set();
let last_para = null;
let last_at = null;
for (const cp of childRows) {
let arr = [];
try { arr = JSON.parse(cp.paragraphs_read || '[]'); } catch {}
for (const item of arr) readSet.add(item);
/* childRows ordered by last_at DESC — first row is the most recent. */
if (last_para === null) {
last_para = cp.last_para;
last_at = cp.last_at;
}
}
return {
...t,
progress: { read: [...readSet], last_para, last_at },
};
});
res.json({ textbooks });
});
/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */
@@ -123,6 +165,43 @@ router.delete('/bookmarks/:id', (req, res) => {
:slug ROUTES (catch-all per textbook)
════════════════════════════════════════════════ */
/* GET /api/textbooks/:slug/children — chapter list for a hub textbook, with per-user progress.
Used by hub pages to render chapter cards and progress bars. */
// @public-by-design: router-level authMiddleware (line 7) covers all routes in this file
router.get('/:slug/children', (req, res) => {
const hub = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug);
if (!hub) return res.status(404).json({ error: 'Учебник не найден' });
const children = db.prepare(`
SELECT t.id, t.slug, t.title, t.description, t.html_path, t.para_count, t.sort_order, t.color
FROM textbooks t
WHERE t.parent_slug = ? AND t.is_active = 1
ORDER BY t.sort_order
`).all(req.params.slug);
const progressRows = db.prepare(`
SELECT textbook_id, paragraphs_read, last_para, last_at
FROM textbook_progress
WHERE user_id = ? AND textbook_id IN (
SELECT id FROM textbooks WHERE parent_slug = ? AND is_active = 1
)
`).all(req.user.id, req.params.slug);
const progMap = {};
for (const p of progressRows) {
let arr = [];
try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {}
progMap[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at };
}
res.json({
children: children.map(c => ({
...c,
progress: progMap[c.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);