From 699fdcc7fb3493cb6f84181a0a74a6ae017b881c Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 27 May 2026 16:49:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(catalog):=20=D1=85=D0=B0=D0=B1-=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=90=D0=BB=D0=B3=D0=B5=D0=B1=D1=80=D1=8B=208=20(3=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D0=B2=D1=8B=20=D0=BF=D0=BE=D0=B4=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=BC=20=D1=81=D0=BB=D0=B0=D0=B3=D0=BE=D0=BC?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/db/migrations/014_algebra_8_hub.sql | 23 ++ backend/src/routes/textbooks.js | 89 ++++- frontend/textbooks/algebra_8.html | 10 +- frontend/textbooks/algebra_8_ch2.html | 8 +- frontend/textbooks/algebra_8_ch3.html | 8 +- frontend/textbooks/algebra_8_hub.html | 355 ++++++++++++++++++ 6 files changed, 469 insertions(+), 24 deletions(-) create mode 100644 backend/src/db/migrations/014_algebra_8_hub.sql create mode 100644 frontend/textbooks/algebra_8_hub.html diff --git a/backend/src/db/migrations/014_algebra_8_hub.sql b/backend/src/db/migrations/014_algebra_8_hub.sql new file mode 100644 index 0000000..312bbed --- /dev/null +++ b/backend/src/db/migrations/014_algebra_8_hub.sql @@ -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'); diff --git a/backend/src/routes/textbooks.js b/backend/src/routes/textbooks.js index c98ffe5..3ca22ca 100644 --- a/backend/src/routes/textbooks.js +++ b/backend/src/routes/textbooks.js @@ -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); diff --git a/frontend/textbooks/algebra_8.html b/frontend/textbooks/algebra_8.html index a494864..3f128fa 100644 --- a/frontend/textbooks/algebra_8.html +++ b/frontend/textbooks/algebra_8.html @@ -760,13 +760,9 @@ input,select,textarea{font-family:inherit} - - Глава 3 - - - - Глава 2 - + + + К алгебре 8 + + + + +
+ +
+

Алгебра 8 класса

+

Интерактивный учебник по алгебре 8 класса. Три главы охватывают весь курс: квадратные корни и действительные числа, квадратные уравнения, неравенства с одной переменной.

+

21 параграф, 3 финала глав, 100+ интерактивных заданий, 21 босс-проверка. По учебнику Арефьевой И. Г., Пирютко О. Н. (2018).

+
+ 21 параграф + 3 главы + 100+ интерактивов + 21 босс-проверка +
+
+ +
+
x
+
+
Общий прогресс по курсу
+
Загрузка...
+
+
+ +
+ +
+ + +
+
I
+
Глава 1
+
Квадратные корни. Действительные числа
+
§1–§6 + Финал
+
+
+
Понятие квадратного корня, арифметический корень, свойства квадратных корней, упрощение выражений с корнями, числовые неравенства, действительные числа.
+
+ Корни + Иррациональность + 6 параграфов +
+
+
Прогресс0%
+
+
+
+ Открыть главу + +
+
+
+ + +
+
II
+
Глава 2
+
Квадратные уравнения
+
§7–§12 + Финал
+
+
+
Квадратные уравнения и их решение, формула дискриминанта, теорема Виета, уравнения, сводимые к квадратным, задачи на составление уравнений.
+
+ Дискриминант + Теорема Виета + 7 параграфов +
+
+
Прогресс0%
+
+
+
+ Открыть главу + +
+
+
+ + +
+
III
+
Глава 3
+
Неравенства с одной переменной
+
§13–§18 + Финал
+
+
+
Числовые неравенства, линейные неравенства с одной переменной, системы и совокупности неравенств, метод интервалов, квадратные неравенства.
+
+ Метод интервалов + Системы + 7 параграфов +
+
+
Прогресс0%
+
+
+
+ Открыть главу + +
+
+
+ +
+ +
+
+ + + +
+
+
Магистр алгебры 8
+
Прочитайте все 21 параграф трёх глав, чтобы получить достижение
+
+
+ +
+ +
+ Интерактивный учебник «Алгебра — 8 класс» · LearnSpace +
+ + + + +