Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov e8018d85c1 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 идемпотентная; сиды двух учебников включены.
2026-05-16 14:05:19 +03:00

466 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Учебники — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
.sb-content { padding: 0; overflow-y: auto; }
.tb-wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 80px; width: 100%; }
.tb-header { display:flex; align-items:center; gap:14px; margin-bottom:30px; }
.tb-icon {
width:52px; height:52px; border-radius:14px; flex-shrink:0;
background:linear-gradient(135deg, rgba(155,93,229,.25), rgba(6,214,224,.18));
border:1.5px solid rgba(255,255,255,.1);
display:flex; align-items:center; justify-content:center;
}
.tb-icon svg { width:26px; height:26px; stroke:#9B5DE5; stroke-width:1.8; fill:none; }
.tb-title { font-family:'Unbounded',sans-serif; font-size:1.35rem; font-weight:800; letter-spacing:-.02em; }
.tb-sub { font-size:.82rem; color:var(--text-2); margin-top:2px; }
.tb-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap:22px;
}
.tb-card {
background:var(--surface);
border:1.5px solid var(--border);
border-radius:18px; overflow:hidden;
transition: border-color .18s, box-shadow .18s, transform .18s;
display:flex; flex-direction:column;
}
.tb-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 36px rgba(0,0,0,.18);
}
.tb-cover {
height:140px; position:relative; overflow:hidden;
display:flex; align-items:flex-end; padding:18px 22px 14px;
}
.tb-cover.amber { background:linear-gradient(135deg, #b45309 0%, #d97706 60%, #f59e0b 100%); }
.tb-cover.blue { background:linear-gradient(135deg, #1e40af 0%, #2563eb 60%, #3b82f6 100%); }
.tb-cover.green { background:linear-gradient(135deg, #047857 0%, #059669 60%, #10b981 100%); }
.tb-cover.violet { background:linear-gradient(135deg, #6d28d9 0%, #7c3aed 60%, #9333ea 100%); }
.tb-cover.pink { background:linear-gradient(135deg, #be185d 0%, #db2777 60%, #ec4899 100%); }
.tb-cover::before {
content: attr(data-watermark);
position:absolute; right:-10px; top:-15%;
font-family:'Unbounded',sans-serif; font-weight:900;
font-size:clamp(3rem, 9vw, 7rem); letter-spacing:-.04em; line-height:1;
color:transparent; -webkit-text-stroke:1.5px rgba(255,255,255,.18);
pointer-events:none; user-select:none;
}
.tb-cover-info {
position:relative; z-index:1; color:#fff;
}
.tb-cover-grade {
display:inline-flex; align-items:center; gap:4px;
padding:3px 10px; border-radius:99px;
background:rgba(255,255,255,.18); backdrop-filter:blur(4px);
font-size:.7rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
margin-bottom:6px;
}
.tb-cover-title {
font-family:'Unbounded',sans-serif; font-weight:800;
font-size:1.15rem; letter-spacing:-.01em;
}
.tb-body {
padding:16px 20px 18px; flex:1;
display:flex; flex-direction:column; gap:10px;
}
.tb-author {
font-size:.78rem; color:var(--text-2); font-weight:600;
display:inline-flex; align-items:center; gap:6px;
}
.tb-author svg { width:13px; height:13px; opacity:.7; }
.tb-desc {
font-size:.85rem; line-height:1.55; color:var(--text-2);
flex:1;
}
.tb-progress {
margin-top:6px;
padding-top:12px; border-top:1px solid var(--border);
}
.tb-progress-bar {
height:6px; border-radius:99px; background:var(--border); overflow:hidden;
margin-bottom:7px;
}
.tb-progress-fill {
height:100%; border-radius:99px;
transition: width .3s ease;
}
.tb-progress.amber .tb-progress-fill { background:#d97706; }
.tb-progress.blue .tb-progress-fill { background:#2563eb; }
.tb-progress.green .tb-progress-fill { background:#059669; }
.tb-progress.violet .tb-progress-fill { background:#7c3aed; }
.tb-progress.pink .tb-progress-fill { background:#db2777; }
.tb-progress-text {
display:flex; justify-content:space-between; align-items:center;
font-size:.74rem; color:var(--text-3);
}
.tb-progress-text b { color:var(--text); font-weight:700; }
.tb-actions {
display:flex; gap:8px; margin-top:12px;
}
.tb-btn {
flex:1; padding:9px 14px; border-radius:10px;
border:1.5px solid var(--border-h); background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
cursor:pointer; transition:all .15s; text-decoration:none;
display:inline-flex; align-items:center; justify-content:center; gap:6px;
}
.tb-btn:hover { border-color:var(--text-2); }
.tb-btn.primary {
border-color:transparent; color:#fff;
}
.tb-btn.primary.amber { background:#d97706; }
.tb-btn.primary.blue { background:#2563eb; }
.tb-btn.primary.green { background:#059669; }
.tb-btn.primary.violet { background:#7c3aed; }
.tb-btn.primary.pink { background:#db2777; }
.tb-btn.primary:hover { filter:brightness(1.1); }
.tb-btn svg { width:14px; height:14px; }
.tb-assign-btn {
width:auto; min-width:42px; padding:9px 12px;
flex:0 0 auto;
}
.tb-empty {
grid-column: 1 / -1;
padding:60px 20px; text-align:center; color:var(--text-3);
}
.tb-empty svg { width:48px; height:48px; opacity:.5; margin-bottom:14px; stroke:var(--text-3); }
/* ── Assign modal (reused styling from exam9) ── */
.ex-overlay {
display:none; position:fixed; inset:0;
background:rgba(15,23,42,.55); z-index:300;
align-items:flex-start; justify-content:center; padding-top:80px;
backdrop-filter:blur(2px);
}
.ex-overlay.visible { display:flex; }
.ex-panel {
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.32);
width:min(520px, 94vw); max-height:calc(100vh - 120px);
overflow-y:auto; padding:22px 22px 26px;
}
.ex-panel-head {
display:flex; align-items:center; justify-content:space-between; margin-bottom:18px;
}
.ex-panel-head h2 { font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:800; }
.ex-panel-close {
width:32px; height:32px; border:none; background:none;
color:var(--text-2); cursor:pointer; border-radius:8px;
display:flex; align-items:center; justify-content:center; transition:background .15s;
}
.ex-panel-close:hover { background:var(--border); color:var(--text); }
.ex-panel-close svg { width:18px; height:18px; }
.ax-form { display:flex; flex-direction:column; gap:14px; }
.ax-field label {
display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
}
.ax-classes {
display:flex; flex-direction:column; gap:6px; max-height:200px; overflow-y:auto;
border:1.5px solid var(--border); border-radius:10px; padding:8px;
}
.ax-class {
display:flex; align-items:center; gap:10px; padding:8px 10px;
border-radius:8px; cursor:pointer; transition:background .12s;
font-size:.9rem;
}
.ax-class:hover { background:var(--border); }
.ax-class input { accent-color:var(--violet); flex-shrink:0; }
.ax-class .ax-cname { font-weight:600; }
.ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; }
.ax-input {
width:100%; padding:9px 12px; border:1.5px solid var(--border-h);
border-radius:9px; background:var(--surface); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ax-input:focus { outline:none; border-color:var(--violet); }
.ax-hint { font-size:.74rem; color:var(--text-3); margin-top:4px; }
.ax-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:6px; }
.ax-btn {
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:all .15s;
}
.ax-btn:hover { border-color:var(--text-2); }
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.ax-error, .ax-success {
padding:9px 12px; border-radius:8px; font-size:.84rem; display:none;
}
.ax-error.visible { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
.ax-success.visible { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="tb-wrap">
<header class="tb-header">
<div class="tb-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<line x1="9" y1="7" x2="15" y2="7"/>
<line x1="9" y1="11" x2="15" y2="11"/>
</svg>
</div>
<div>
<div class="tb-title">Учебники</div>
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
</div>
</header>
<div class="tb-grid" id="tb-grid">
<div class="tb-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
<div>Загрузка…</div>
</div>
</div>
</div>
</div>
</div>
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
<div class="ex-panel" onclick="event.stopPropagation()">
<div class="ex-panel-head">
<h2 id="assign-title">Назначить чтение</h2>
<button class="ex-panel-close" onclick="closeAssignModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
<div class="ax-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field">
<label>Параграфы</label>
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»)</div>
</div>
<div class="ax-field">
<label>Срок сдачи</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
<div class="ax-error" id="ax-error"></div>
<div class="ax-success" id="ax-success"></div>
<div class="ax-actions">
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script>
(async function () {
const user = LS.initPage();
LS.showBoardIfAllowed();
LS.hideDisabledFeatures();
const isTeacher = user && (user.role === 'teacher' || user.role === 'admin');
let textbooks = [];
let teacherClasses = null;
function esc(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadTextbooks() {
try {
const r = await LS.api('/api/textbooks');
textbooks = r.textbooks || [];
render();
} catch (e) {
document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`;
}
}
function render() {
const grid = document.getElementById('tb-grid');
if (!textbooks.length) {
grid.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>';
return;
}
grid.innerHTML = textbooks.map(t => {
const readCount = (t.progress?.read || []).length;
const pct = t.para_count ? Math.round(100 * readCount / t.para_count) : 0;
const watermark = t.subject === 'chemistry' ? 'Х' : t.subject === 'physics' ? 'Φ' : t.subject === 'math' ? 'Σ' : t.subject === 'biology' ? 'Β' : '§';
const continueHref = t.progress?.last_para
? `/textbook/${t.slug}#${t.progress.last_para}`
: `/textbook/${t.slug}`;
return `
<article class="tb-card">
<div class="tb-cover ${t.color}" data-watermark="${watermark}">
<div class="tb-cover-info">
<div class="tb-cover-grade">${t.grade} класс</div>
<div class="tb-cover-title">${esc(t.title)}</div>
</div>
</div>
<div class="tb-body">
${t.author ? `<div class="tb-author">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
${esc(t.author)}
</div>` : ''}
<div class="tb-desc">${esc(t.description)}</div>
<div class="tb-progress ${t.color}">
<div class="tb-progress-bar">
<div class="tb-progress-fill" style="width:${pct}%"></div>
</div>
<div class="tb-progress-text">
<span><b>${readCount}</b> из ${t.para_count} прочитано</span>
<span>${pct}%</span>
</div>
</div>
<div class="tb-actions">
<a href="${continueHref}" class="tb-btn primary ${t.color}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
${t.progress?.last_para ? 'Продолжить' : 'Открыть'}
</a>
${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
</button>` : ''}
</div>
</div>
</article>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
/* ── Assign modal ── */
let assignSlug = null;
let assignTitle = null;
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
try {
const list = await LS.api('/api/classes');
teacherClasses = Array.isArray(list) ? list : [];
} catch { teacherClasses = []; }
return teacherClasses;
}
window.openAssignModal = async function (slug, title) {
assignSlug = slug;
assignTitle = title;
document.getElementById('assign-title').textContent = `Назначить чтение: «${title}»`;
['ax-error', 'ax-success'].forEach(id => document.getElementById(id).classList.remove('visible'));
document.getElementById('ax-paragraphs').value = '';
document.getElementById('ax-deadline').value = '';
document.getElementById('ax-submit').disabled = false;
document.getElementById('ax-submit').textContent = 'Назначить';
const listEl = document.getElementById('ax-classes-list');
listEl.textContent = 'Загрузка…';
const classes = await loadTeacherClasses();
if (!classes.length) {
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
} else {
listEl.innerHTML = classes.map(c => `
<label class="ax-class">
<input type="checkbox" name="cls" value="${c.id}" />
<span class="ax-cname">${esc(c.name)}</span>
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
</label>`).join('');
}
document.getElementById('assign-overlay').classList.add('visible');
document.addEventListener('keydown', onAssignEsc);
};
window.closeAssignModal = function () {
document.getElementById('assign-overlay').classList.remove('visible');
document.removeEventListener('keydown', onAssignEsc);
};
window.onAssignOverlayClick = function (e) {
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
};
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
window.submitAssign = async function () {
const errorEl = document.getElementById('ax-error');
const successEl = document.getElementById('ax-success');
const submitBtn = document.getElementById('ax-submit');
errorEl.classList.remove('visible');
successEl.classList.remove('visible');
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
.map(el => Number(el.value));
if (!checked.length) {
errorEl.textContent = 'Выберите хотя бы один класс';
errorEl.classList.add('visible');
return;
}
const paragraphs = document.getElementById('ax-paragraphs').value.trim();
const deadline = document.getElementById('ax-deadline').value || null;
submitBtn.disabled = true;
submitBtn.textContent = 'Назначаю…';
try {
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
class_ids: checked,
mode: 'exam', // mode is required, but for textbook assignment is informational
count: 1,
subject_slug: 'other',
is_homework: 1,
deadline: deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
successEl.textContent = `Назначено в ${r.count || checked.length} класс(е/ах)`;
successEl.classList.add('visible');
submitBtn.textContent = 'Готово';
setTimeout(closeAssignModal, 1500);
} catch (e) {
errorEl.textContent = e.message || 'Не удалось создать задание';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Назначить';
}
};
await loadTextbooks();
})();
</script>
</body>
</html>