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);
+3 -7
View File
@@ -760,13 +760,9 @@ input,select,textarea{font-family:inherit}
<svg id="sound-on-ic" class="ic" viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
<svg id="sound-off-ic" class="ic" viewBox="0 0 24 24" style="display:none"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
</button>
<a href="/textbook/algebra-8-ch3" class="hdr-btn" title="Глава 3 — Неравенства">
Глава 3
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</a>
<a href="/textbook/algebra-8-ch2" class="hdr-btn" title="Глава 2 — Квадратные уравнения">
Глава 2
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
<a href="/textbook/algebra-8" class="hdr-btn" title="К Алгебре 8 — все главы">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
К алгебре 8
</a>
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
+2 -6
View File
@@ -337,13 +337,9 @@ input,select,textarea{font-family:inherit}
<div class="hdr-sub">Квадратные уравнения</div>
</div>
<div class="hdr-side">
<a href="/textbook/algebra-8" class="hdr-btn" title="К Главе 1">
<a href="/textbook/algebra-8" class="hdr-btn" title="К Алгебре 8 — все главы">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Глава 1
</a>
<a href="/textbook/algebra-8-ch3" class="hdr-btn" title="К Главе 3">
Глава 3
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
К алгебре 8
</a>
<button id="search-btn" class="hdr-btn" title="Поиск (Ctrl+K)">
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
+2 -6
View File
@@ -263,13 +263,9 @@ a{color:inherit;text-decoration:none}
<div class="hdr-sub">Неравенства с одной переменной</div>
</div>
<div class="hdr-side">
<a href="/textbook/algebra-8" class="hdr-btn" title="К Главе 1">
<a href="/textbook/algebra-8" class="hdr-btn" title="К Алгебре 8 — все главы">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Глава 1
</a>
<a href="/textbook/algebra-8-ch2" class="hdr-btn" title="К Главе 2">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
Глава 2
К алгебре 8
</a>
<button id="search-btn" class="hdr-btn" title="Поиск (Ctrl+K)">
<svg class="ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Алгебра 8 класс — учебник</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<style>
:root{
--bg:#fdf2f8; --card:#fff;
--text:#1a1a2e; --muted:#6b5b73;
--border:#fce7f3;
--pri:#e91e63; --pri-d:#c2185b;
--pri-soft:#fce7f3;
--ch1:#e91e63; --ch1-d:#c2185b;
--ch2:#ad1457; --ch2-d:#880e4f;
--ch3:#6366f1; --ch3-d:#4f46e5;
--sh:0 4px 16px rgba(233,30,99,.09);
--sh-h:0 12px 36px rgba(233,30,99,.18);
}
html.dark{
--bg:#1a0f1a; --card:#2a1929;
--text:#f5e6f0; --muted:#b0a0b0;
--border:#5a2a5a;
--pri-soft:rgba(233,30,99,.16);
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100vh}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
/* HEADER */
.hdr{position:relative;background:linear-gradient(110deg,#c2185b 0%,#e91e63 55%,#ec407a 100%);color:#fff;padding:32px 24px 28px;overflow:hidden;border-bottom:2px solid rgba(255,180,210,.15)}
.hdr::before{content:'АЛГЕБРА';position:absolute;right:-14px;top:-18%;font-family:'Outfit',sans-serif;font-size:clamp(5rem,16vw,13rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,220,235,.10);line-height:1;pointer-events:none;user-select:none}
.hdr-inner{position:relative;z-index:1;max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.14);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
.hdr-back:hover{background:rgba(255,255,255,.24)}
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.85rem;font-weight:900;letter-spacing:-.01em}
.hdr-sub{font-size:.92rem;opacity:.85;margin-top:4px}
.hdr-side{margin-left:auto;display:flex;gap:8px}
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.14);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit}
.hdr-btn:hover{background:rgba(255,255,255,.24)}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
main{max-width:1100px;margin:0 auto;padding:32px 24px 60px}
/* INTRO */
.intro{background:var(--card);border:1px solid var(--border);border-radius:18px;padding:24px 26px;margin-bottom:28px;box-shadow:var(--sh);position:relative;overflow:hidden}
.intro::before{content:'x';position:absolute;right:-10px;top:-30px;font-size:12rem;font-style:italic;opacity:.04;line-height:1;pointer-events:none;font-family:'Inter',serif;font-weight:900;color:var(--pri-d)}
.intro h2{font-family:'Outfit',sans-serif;font-size:1.45rem;font-weight:800;color:var(--pri-d);margin-bottom:10px;letter-spacing:-.01em}
.intro p{font-size:1.02rem;color:var(--text);opacity:.9;max-width:720px;margin-bottom:8px}
.intro .meta{margin-top:14px;display:flex;gap:18px;flex-wrap:wrap;font-size:.86rem;color:var(--muted)}
.intro .meta b{color:var(--pri-d)}
/* OVERALL PROGRESS */
.prog-overall{background:linear-gradient(135deg,var(--pri-soft),rgba(99,102,241,.12));border:1px solid var(--border);border-radius:14px;padding:14px 18px;margin-bottom:28px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
.po-icon{width:46px;height:46px;border-radius:12px;background:linear-gradient(135deg,#e91e63,#6366f1);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:'Outfit',sans-serif;font-size:1.4rem;font-weight:900}
.po-text{flex:1;min-width:160px}
.po-label{font-size:.78rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
.po-bar{height:8px;background:rgba(233,30,99,.12);border-radius:5px;overflow:hidden;margin-top:6px}
.po-fill{height:100%;background:linear-gradient(90deg,var(--pri),#6366f1);border-radius:5px;transition:width .5s}
.po-xp{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:linear-gradient(135deg,#f59e0b,var(--pri));color:#fff;border-radius:99px;font-size:.8rem;font-weight:800;font-family:'Unbounded',sans-serif;letter-spacing:.02em;box-shadow:0 4px 12px rgba(233,30,99,.22)}
/* CHAPTER GRID */
.ch-grid{display:grid;grid-template-columns:1fr;gap:18px;margin-bottom:30px}
@media(min-width:760px){.ch-grid{grid-template-columns:1fr 1fr 1fr}}
.ch-card{background:var(--card);border:1.5px solid var(--border);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;transition:transform .2s,box-shadow .2s,border-color .2s;cursor:pointer;text-decoration:none;color:inherit}
.ch-card:hover{transform:translateY(-4px);box-shadow:var(--sh-h)}
.ch-cover{padding:22px 22px 18px;color:#fff;position:relative;overflow:hidden}
.ch-cover-wm{position:absolute;right:-10px;top:-30px;font-size:8rem;font-weight:900;font-family:'Outfit',sans-serif;line-height:1;letter-spacing:-.04em;color:rgba(255,255,255,.13);pointer-events:none}
.ch-num{display:inline-block;padding:4px 10px;background:rgba(255,255,255,.22);border-radius:99px;font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;position:relative;z-index:1}
.ch-title{font-family:'Outfit',sans-serif;font-size:1.2rem;font-weight:800;letter-spacing:-.01em;position:relative;z-index:1;line-height:1.3}
.ch-range{font-size:.86rem;opacity:.85;margin-top:4px;position:relative;z-index:1;font-weight:500}
.ch-cover.ch1{background:linear-gradient(135deg,#880e4f,#e91e63 60%,#ec407a)}
.ch-cover.ch2{background:linear-gradient(135deg,#560027,#ad1457 60%,#c2185b)}
.ch-cover.ch3{background:linear-gradient(135deg,#312e81,#6366f1 60%,#818cf8)}
.ch-body{padding:18px 22px 20px;display:flex;flex-direction:column;flex:1}
.ch-desc{font-size:.9rem;color:var(--text);opacity:.82;flex:1;margin-bottom:14px;line-height:1.55}
.ch-tags{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:14px}
.ch-tag{padding:3px 9px;border-radius:6px;font-size:.72rem;font-weight:600}
.ch-tag.pink{background:var(--pri-soft);color:var(--pri-d)}
.ch-tag.indigo{background:rgba(99,102,241,.14);color:#4f46e5}
html.dark .ch-tag.indigo{color:#818cf8}
.ch-prog{margin-bottom:14px}
.ch-prog-label{display:flex;justify-content:space-between;font-size:.74rem;color:var(--muted);font-weight:600;margin-bottom:4px}
.ch-prog-bar{height:6px;background:rgba(233,30,99,.10);border-radius:4px;overflow:hidden}
.ch-prog-fill{height:100%;border-radius:4px;transition:width .5s}
.ch-card.ch1-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch1),var(--ch1-d))}
.ch-card.ch2-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch2),var(--ch2-d))}
.ch-card.ch3-card .ch-prog-fill{background:linear-gradient(90deg,var(--ch3),var(--ch3-d))}
.ch-action{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-radius:11px;font-weight:700;font-size:.92rem;color:#fff;transition:filter .15s}
.ch-action:hover{filter:brightness(1.08)}
.ch-card.ch1-card .ch-action{background:linear-gradient(135deg,var(--ch1),#ec407a)}
.ch-card.ch2-card .ch-action{background:linear-gradient(135deg,var(--ch2),#d81b60)}
.ch-card.ch3-card .ch-action{background:linear-gradient(135deg,var(--ch3),#818cf8)}
/* ACHIEVEMENT STRIP */
.ach-strip{background:var(--card);border:1.5px solid var(--border);border-radius:16px;padding:18px 22px;margin-bottom:28px;display:flex;align-items:center;gap:16px;transition:border-color .4s,box-shadow .4s}
.ach-strip.lit{border-color:#f59e0b;box-shadow:0 0 0 3px rgba(245,158,11,.18)}
.ach-icon{width:52px;height:52px;border-radius:14px;background:rgba(0,0,0,.06);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .4s}
.ach-strip.lit .ach-icon{background:linear-gradient(135deg,#fbbf24,#f59e0b)}
.ach-icon svg{width:28px;height:28px;stroke:var(--muted)}
.ach-strip.lit .ach-icon svg{stroke:#fff}
.ach-text{flex:1}
.ach-title{font-weight:800;font-size:1.02rem;color:var(--text)}
.ach-sub{font-size:.85rem;color:var(--muted);margin-top:2px}
.ach-strip.lit .ach-title{color:#92400e}
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border)}
</style>
</head>
<body>
<header class="hdr">
<div class="hdr-inner">
<div>
<a href="/textbooks" class="hdr-back">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
К каталогу
</a>
</div>
<div>
<h1>Алгебра — 8 класс</h1>
<div class="hdr-sub">Полный курс: корни, уравнения, неравенства</div>
</div>
<div class="hdr-side">
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span id="theme-lab">Тёмная</span>
</button>
</div>
</div>
</header>
<main>
<section class="intro">
<h2>Алгебра 8 класса</h2>
<p>Интерактивный учебник по алгебре 8 класса. Три главы охватывают весь курс: квадратные корни и действительные числа, квадратные уравнения, неравенства с одной переменной.</p>
<p>21 параграф, 3 финала глав, 100+ интерактивных заданий, 21 босс-проверка. По учебнику Арефьевой И. Г., Пирютко О. Н. (2018).</p>
<div class="meta">
<span><b>21</b> параграф</span>
<span><b>3</b> главы</span>
<span><b>100+</b> интерактивов</span>
<span><b>21</b> босс-проверка</span>
</div>
</section>
<section class="prog-overall">
<div class="po-icon">x</div>
<div class="po-text">
<div class="po-label">Общий прогресс по курсу</div>
<div id="overall-text" style="font-size:1.05rem;font-weight:700">Загрузка...</div>
<div class="po-bar"><div id="overall-fill" class="po-fill" style="width:0%"></div></div>
</div>
<div id="hero-xp-badge" class="po-xp" style="display:none">0 XP</div>
</section>
<div class="ch-grid">
<a href="/textbook/algebra-8-ch1" class="ch-card ch1-card" id="ch-1">
<div class="ch-cover ch1">
<div class="ch-cover-wm">I</div>
<div class="ch-num">Глава 1</div>
<div class="ch-title">Квадратные корни. Действительные числа</div>
<div class="ch-range">§1–§6 + Финал</div>
</div>
<div class="ch-body">
<div class="ch-desc">Понятие квадратного корня, арифметический корень, свойства квадратных корней, упрощение выражений с корнями, числовые неравенства, действительные числа.</div>
<div class="ch-tags">
<span class="ch-tag pink">Корни</span>
<span class="ch-tag pink">Иррациональность</span>
<span class="ch-tag pink">6 параграфов</span>
</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-1">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-1" style="width:0%"></div></div>
</div>
<div class="ch-action">
<span id="btn-1">Открыть главу</span>
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</a>
<a href="/textbook/algebra-8-ch2" class="ch-card ch2-card" id="ch-2">
<div class="ch-cover ch2">
<div class="ch-cover-wm">II</div>
<div class="ch-num">Глава 2</div>
<div class="ch-title">Квадратные уравнения</div>
<div class="ch-range">§7–§12 + Финал</div>
</div>
<div class="ch-body">
<div class="ch-desc">Квадратные уравнения и их решение, формула дискриминанта, теорема Виета, уравнения, сводимые к квадратным, задачи на составление уравнений.</div>
<div class="ch-tags">
<span class="ch-tag pink">Дискриминант</span>
<span class="ch-tag pink">Теорема Виета</span>
<span class="ch-tag pink">7 параграфов</span>
</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-2">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-2" style="width:0%"></div></div>
</div>
<div class="ch-action">
<span id="btn-2">Открыть главу</span>
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</a>
<a href="/textbook/algebra-8-ch3" class="ch-card ch3-card" id="ch-3">
<div class="ch-cover ch3">
<div class="ch-cover-wm">III</div>
<div class="ch-num">Глава 3</div>
<div class="ch-title">Неравенства с одной переменной</div>
<div class="ch-range">§13–§18 + Финал</div>
</div>
<div class="ch-body">
<div class="ch-desc">Числовые неравенства, линейные неравенства с одной переменной, системы и совокупности неравенств, метод интервалов, квадратные неравенства.</div>
<div class="ch-tags">
<span class="ch-tag indigo">Метод интервалов</span>
<span class="ch-tag indigo">Системы</span>
<span class="ch-tag indigo">7 параграфов</span>
</div>
<div class="ch-prog">
<div class="ch-prog-label"><span>Прогресс</span><span id="prog-3">0%</span></div>
<div class="ch-prog-bar"><div class="ch-prog-fill" id="fill-3" style="width:0%"></div></div>
</div>
<div class="ch-action">
<span id="btn-3">Открыть главу</span>
<svg class="ic" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</a>
</div>
<div class="ach-strip" id="ach-strip">
<div class="ach-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9H4l-1-3h18l-1 3h-2M6 9l1 6h10l1-6M6 9h12"/><path d="M9 21h6M12 15v6"/>
</svg>
</div>
<div class="ach-text">
<div class="ach-title">Магистр алгебры 8</div>
<div class="ach-sub" id="ach-sub">Прочитайте все 21 параграф трёх глав, чтобы получить достижение</div>
</div>
</div>
</main>
<footer class="foot">
Интерактивный учебник «Алгебра — 8 класс» · LearnSpace
</footer>
<script>
'use strict';
/* THEME */
(function(){
const saved = localStorage.getItem('algebra8_theme') || localStorage.getItem('theme') || 'light';
if (saved === 'dark') document.documentElement.classList.add('dark');
const lab = document.getElementById('theme-lab');
if (lab) lab.textContent = saved === 'dark' ? 'Светлая' : 'Тёмная';
document.getElementById('theme-btn').addEventListener('click', function(){
document.documentElement.classList.toggle('dark');
const dark = document.documentElement.classList.contains('dark');
localStorage.setItem('algebra8_theme', dark ? 'dark' : 'light');
localStorage.setItem('theme', dark ? 'dark' : 'light');
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
});
})();
/* PROGRESS — loaded from /api/textbooks/algebra-8/children via LS.api */
var TOTAL = 21;
/* Para counts per chapter (slugs algebra-8-ch1/ch2/ch3) */
var CH_PARA = { 'algebra-8-ch1': 7, 'algebra-8-ch2': 7, 'algebra-8-ch3': 7 };
/* Map slug to card index 1/2/3 */
var CH_IDX = { 'algebra-8-ch1': 1, 'algebra-8-ch2': 2, 'algebra-8-ch3': 3 };
function setChProg(idx, readCount, total) {
var pct = total ? Math.round(readCount * 100 / total) : 0;
var labelEl = document.getElementById('prog-' + idx);
var fillEl = document.getElementById('fill-' + idx);
var btnEl = document.getElementById('btn-' + idx);
if (labelEl) labelEl.textContent = pct + '%';
if (fillEl) fillEl.style.width = pct + '%';
if (btnEl) {
if (readCount > 0 && readCount < total) btnEl.textContent = 'Продолжить';
else if (readCount >= total) btnEl.textContent = 'Открыть снова';
else btnEl.textContent = 'Открыть главу';
}
return pct;
}
function renderProgress(children) {
var totalRead = 0;
for (var i = 0; i < children.length; i++) {
var ch = children[i];
var idx = CH_IDX[ch.slug];
if (!idx) continue;
var read = ch.progress ? ch.progress.read.length : 0;
var total = ch.para_count || CH_PARA[ch.slug] || 7;
totalRead += read;
setChProg(idx, read, total);
}
var pct = Math.round(totalRead * 100 / TOTAL);
var overallEl = document.getElementById('overall-text');
var fillEl = document.getElementById('overall-fill');
if (overallEl) overallEl.textContent = totalRead + ' из ' + TOTAL + ' параграфов · ' + pct + '%';
if (fillEl) fillEl.style.width = pct + '%';
/* Achievement strip */
if (totalRead >= TOTAL) {
var strip = document.getElementById('ach-strip');
var sub = document.getElementById('ach-sub');
if (strip) strip.classList.add('lit');
if (sub) sub.textContent = 'Выполнено! Вы прочитали весь курс алгебры 8 класса.';
}
}
function loadProgress() {
/* Requires LS.api (window.LS from /js/api.js). Signature: LS.api(path, fetchOptions?) */
if (typeof window.LS === 'undefined' || typeof window.LS.api !== 'function') {
/* Not authenticated: show zeros */
renderProgress([]);
return;
}
window.LS.api('/api/textbooks/algebra-8/children')
.then(function(data) {
if (data && data.children) renderProgress(data.children);
else renderProgress([]);
})
.catch(function() { renderProgress([]); });
}
/* Wait for api.js to be ready */
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadProgress);
} else {
loadProgress();
}
window.addEventListener('focus', loadProgress);
</script>
</body>
</html>