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