Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov df29675cc7 ux: /textbook-progress и /admin-textbooks → вкладки в /textbooks
Раньше: 3 отдельные страницы со своими сайдбарами, header'ами и
скриптами. /textbook-progress был доступен только через кнопку в углу,
/admin-textbooks — только по прямому URL.

Теперь: одна страница /textbooks с тремя вкладками:
  • Каталог (все)
  • Прогресс класса (учитель/админ)
  • Управление (только админ)

URL hash routing: /textbooks#progress, /textbooks#manage. Lazy-init
для каждой вкладки (грузится при первом клике).

Старые страницы превращены в 312-байтные redirect-стабы для
сохранения старых ссылок и закладок:
  /textbook-progress  → /textbooks#progress
  /admin-textbooks    → /textbooks#manage

Effect:
  - Один header, один сайдбар-load, одна загрузка api.js/sidebar.js
  - HTML-страниц сокращено на ~530 строк (textbook-progress.html был
    248 строк, admin-textbooks.html — 219; сейчас ~10 каждая)
  - /textbooks.html: 467 → 945 строк (+478, поглотил функционал двух
    страниц с собственными стилями)
  - Чистый UX: всё про учебники в одном месте, переключение
    мгновенное (нет полной перезагрузки страницы)
2026-05-16 17:39:13 +03:00

923 lines
40 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); }
/* ── Tabs ── */
.tb-tabs {
display:flex; gap:4px; margin-bottom:24px;
background:var(--surface); border:1.5px solid var(--border);
border-radius:12px; padding:4px;
}
.tb-tab {
flex:0 0 auto; padding:9px 18px; border-radius:8px;
border:none; background:transparent; color:var(--text-2);
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:all .14s;
display:inline-flex; align-items:center; gap:7px;
}
.tb-tab:hover { color:var(--text); }
.tb-tab.active {
background:var(--violet); color:#fff;
box-shadow: 0 2px 8px rgba(155,93,229,.25);
}
.tb-tab svg { width:14px; height:14px; }
.tb-panel { display:none; }
.tb-panel.active { display:block; }
/* ── Class-progress tab styles ── */
.tp-pickers {
display:flex; gap:12px; margin-bottom:24px; flex-wrap:wrap;
}
.tp-picker { flex:1; min-width:200px; }
.tp-picker label {
display:block; font-size:.72rem; font-weight:700; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
}
.tp-picker select {
width:100%; padding:10px 12px; border:1.5px solid var(--border-h);
border-radius:10px; background:var(--surface); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.9rem; font-weight:600;
cursor:pointer;
}
.tp-picker select:focus { outline:none; border-color:var(--violet); }
.tp-table {
background:var(--surface); border:1.5px solid var(--border); border-radius:14px;
overflow:hidden;
}
.tp-row {
display:grid; grid-template-columns: 1.5fr 2fr 1fr 1fr;
padding:13px 18px; align-items:center; gap:14px;
border-bottom:1px solid var(--border);
transition: background .12s;
}
.tp-row:last-child { border-bottom:none; }
.tp-row:hover { background:rgba(155,93,229,.04); }
.tp-row.head {
background:rgba(155,93,229,.06); font-family:'Unbounded',sans-serif;
font-size:.72rem; font-weight:800; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em;
}
.tp-row.head:hover { background:rgba(155,93,229,.06); }
.tp-name { font-weight:700; font-size:.92rem; }
.tp-bar { height:8px; border-radius:99px; background:var(--border); overflow:hidden; }
.tp-bar-fill { height:100%; border-radius:99px; transition:width .3s; background:var(--violet); }
.tp-bar-text { font-size:.76rem; color:var(--text-3); margin-top:4px; }
.tp-last { font-size:.82rem; color:var(--text-2); }
.tp-last small { color:var(--text-3); }
@media (max-width: 700px) {
.tp-row { grid-template-columns: 1.5fr 1fr; gap:8px; }
.tp-row > :nth-child(3), .tp-row > :nth-child(4) { display:none; }
}
/* ── Admin manage tab styles ── */
.am-row {
display:grid; grid-template-columns: 2.5fr 1.2fr 1fr 1fr 0.8fr 0.8fr;
padding:14px 18px; align-items:center; gap:14px;
border-bottom:1px solid var(--border);
}
.am-row:last-child { border-bottom:none; }
.am-row.head {
background:rgba(155,93,229,.06);
font-family:'Unbounded',sans-serif;
font-size:.72rem; font-weight:800; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em;
}
.am-row.inactive { opacity:.55; }
.am-input {
width:100%; padding:6px 10px; border:1.5px solid var(--border);
border-radius:7px; background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.85rem;
}
.am-input:focus { outline:none; border-color:var(--violet); }
.am-pill {
display:inline-block; padding:3px 10px; border-radius:99px;
font-size:.72rem; font-weight:700;
background:rgba(155,93,229,.12); color:var(--violet);
}
.am-toggle {
position:relative; width:38px; height:22px; border-radius:99px;
background:var(--border); cursor:pointer; transition:background .15s;
display:inline-block;
}
.am-toggle.on { background:#06D6A0; }
.am-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:18px; height:18px; border-radius:50%;
background:#fff; transition:transform .15s;
box-shadow:0 1px 3px rgba(0,0,0,.2);
}
.am-toggle.on::after { transform:translateX(16px); }
.am-link { color:var(--violet); text-decoration:none; font-weight:700; font-size:.82rem; }
.am-link:hover { text-decoration:underline; }
.am-saved { color:#06D6A0; font-size:.72rem; margin-left:6px; opacity:0; transition:opacity .3s; }
.am-saved.show { opacity:1; }
/* ── 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; }
.ax-tabs { display:flex; gap:6px; background:var(--border); padding:4px; border-radius:10px; }
.ax-tab {
flex:1; padding:7px 12px; border-radius:7px;
border:none; background:transparent; color:var(--text-2);
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
cursor:pointer; transition:all .12s;
}
.ax-tab:hover { color:var(--text); }
.ax-tab.active { background:var(--surface); color:var(--violet); box-shadow:0 1px 4px rgba(0,0,0,.08); }
.ax-student-results {
margin-top:6px; max-height:160px; overflow-y:auto;
border:1.5px solid var(--border); border-radius:10px;
display:none;
}
.ax-student-results.visible { display:block; }
.ax-student-row {
padding:8px 12px; cursor:pointer; transition:background .12s;
display:flex; align-items:center; gap:10px;
font-size:.85rem;
}
.ax-student-row:hover { background:var(--border); }
.ax-student-row.selected { background:rgba(155,93,229,.12); color:var(--violet); }
.ax-student-row .ax-student-email { font-size:.75rem; color:var(--text-3); margin-left:auto; }
</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 style="flex:1">
<div class="tb-title">Учебники</div>
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
</div>
<div id="tb-header-actions"></div>
</header>
<div class="tb-tabs" id="tb-tabs">
<button class="tb-tab active" data-tab="catalog" onclick="setTab('catalog')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
Каталог
</button>
<button class="tb-tab tb-tab-teacher" data-tab="progress" onclick="setTab('progress')" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Прогресс класса
</button>
<button class="tb-tab tb-tab-admin" data-tab="manage" onclick="setTab('manage')" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Управление
</button>
</div>
<!-- TAB: catalog -->
<div class="tb-panel active" id="tb-panel-catalog">
<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>
<!-- TAB: class progress (teacher/admin) -->
<div class="tb-panel" id="tb-panel-progress">
<div class="tp-pickers">
<div class="tp-picker">
<label>Учебник</label>
<select id="tp-textbook"></select>
</div>
<div class="tp-picker">
<label>Класс</label>
<select id="tp-class"></select>
</div>
</div>
<div id="tp-result" class="tb-empty">Выберите учебник и класс</div>
</div>
<!-- TAB: manage (admin only) -->
<div class="tb-panel" id="tb-panel-manage">
<div id="am-content" class="tb-empty">Загрузка…</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-tabs">
<button type="button" class="ax-tab active" data-tab="class" onclick="setAssignTab('class')">Классу</button>
<button type="button" class="ax-tab" data-tab="student" onclick="setAssignTab('student')">Ученику</button>
</div>
</div>
<div class="ax-field" id="ax-class-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field" id="ax-student-field" style="display:none">
<label>Ученик</label>
<input type="text" class="ax-input" id="ax-student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
<div class="ax-student-results" id="ax-student-results"></div>
<input type="hidden" id="ax-student-id" />
</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');
const isAdmin = user && user.role === 'admin';
let textbooks = [];
let teacherClasses = null;
// Reveal teacher/admin tabs
if (isTeacher) document.querySelectorAll('.tb-tab-teacher').forEach(el => el.style.display = '');
if (isAdmin) document.querySelectorAll('.tb-tab-admin').forEach(el => el.style.display = '');
/* ── Tab routing ── */
const VALID_TABS = ['catalog', 'progress', 'manage'];
let _progressInited = false;
let _manageInited = false;
window.setTab = function (name) {
if (!VALID_TABS.includes(name)) name = 'catalog';
if (name === 'progress' && !isTeacher) name = 'catalog';
if (name === 'manage' && !isAdmin) name = 'catalog';
document.querySelectorAll('.tb-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.tb-panel').forEach(p => p.classList.toggle('active', p.id === 'tb-panel-' + name));
history.replaceState(null, '', '#' + name);
if (name === 'progress' && !_progressInited) { _progressInited = true; initProgressTab(); }
if (name === 'manage' && !_manageInited) { _manageInited = true; initManageTab(); }
};
// Initial tab from URL hash
const initialTab = (location.hash || '').replace('#', '') || 'catalog';
setTimeout(() => setTab(initialTab), 0);
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;
let assignTab = 'class'; // 'class' or 'student'
let teacherStudents = null; // cached list of students-in-teacher's-classes
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
try {
const list = await LS.api('/api/classes');
teacherClasses = Array.isArray(list) ? list : [];
} catch { teacherClasses = []; }
return teacherClasses;
}
async function loadTeacherStudents() {
if (teacherStudents) return teacherStudents;
try {
const r = await LS.api('/api/classes/students');
teacherStudents = Array.isArray(r) ? r : (r.students || []);
} catch { teacherStudents = []; }
return teacherStudents;
}
window.setAssignTab = function (tab) {
assignTab = tab;
document.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('ax-class-field').style.display = tab === 'class' ? '' : 'none';
document.getElementById('ax-student-field').style.display = tab === 'student' ? '' : 'none';
document.getElementById('ax-student-id').value = '';
document.getElementById('ax-student-search').value = '';
document.getElementById('ax-student-results').classList.remove('visible');
};
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 = 'Назначить';
setAssignTab('class');
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(); }
/* Student search (debounced) */
let stSearchTimer = null;
document.addEventListener('input', e => {
if (e.target?.id !== 'ax-student-search') return;
clearTimeout(stSearchTimer);
stSearchTimer = setTimeout(() => filterStudents(e.target.value), 200);
});
async function filterStudents(q) {
const resultsEl = document.getElementById('ax-student-results');
q = q.trim().toLowerCase();
if (q.length < 2) { resultsEl.classList.remove('visible'); return; }
const students = await loadTeacherStudents();
const matches = students.filter(s =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.email && s.email.toLowerCase().includes(q))
).slice(0, 12);
if (!matches.length) {
resultsEl.innerHTML = '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
} else {
resultsEl.innerHTML = matches.map(s => `
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
<span>${esc(s.name)}</span>
<span class="ax-student-email">${esc(s.email || '')}</span>
</div>`).join('');
}
resultsEl.classList.add('visible');
}
document.addEventListener('click', e => {
const row = e.target.closest('.ax-student-row');
if (!row || !row.dataset.id) return;
document.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
document.getElementById('ax-student-id').value = row.dataset.id;
document.getElementById('ax-student-search').value = row.dataset.name;
document.getElementById('ax-student-results').classList.remove('visible');
});
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 paragraphs = document.getElementById('ax-paragraphs').value.trim();
const deadline = document.getElementById('ax-deadline').value || null;
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
submitBtn.disabled = true;
submitBtn.textContent = 'Назначаю…';
try {
let resultMsg;
if (assignTab === 'class') {
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
.map(el => Number(el.value));
if (!checked.length) throw new Error('Выберите хотя бы один класс');
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
class_ids: checked,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
resultMsg = `Назначено в ${r.count || checked.length} класс(е/ах)`;
} else {
const studentId = Number(document.getElementById('ax-student-id').value);
if (!studentId) throw new Error('Выберите ученика');
await LS.api('/api/assignments', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
student_id: studentId,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
resultMsg = 'Личное задание создано';
}
successEl.textContent = resultMsg;
successEl.classList.add('visible');
submitBtn.textContent = 'Готово';
setTimeout(closeAssignModal, 1500);
} catch (e) {
errorEl.textContent = e.message || 'Не удалось создать задание';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Назначить';
}
};
/* ════════════════════════════════════════════════
TAB: Class progress (teacher/admin)
════════════════════════════════════════════════ */
function fmtDate(s) {
if (!s) return '';
try { return new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z').toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' }); }
catch { return s; }
}
async function initProgressTab() {
const tbSel = document.getElementById('tp-textbook');
const clsSel = document.getElementById('tp-class');
const resEl = document.getElementById('tp-result');
// Load classes
const classes = await LS.api('/api/classes').catch(() => []);
const list = Array.isArray(classes) ? classes : [];
if (!list.length) {
resEl.innerHTML = '<div class="tb-empty">У вас нет классов</div>';
return;
}
// Populate selects (textbooks already loaded)
textbooks.forEach((t, i) => {
const o = document.createElement('option');
o.value = t.slug;
o.textContent = `${t.title} (§1${t.para_count})`;
if (i === 0) o.selected = true;
tbSel.appendChild(o);
});
list.forEach((c, i) => {
const o = document.createElement('option');
o.value = c.id;
o.textContent = `${c.name} (${c.member_count || 0} учеников)`;
if (i === 0) o.selected = true;
clsSel.appendChild(o);
});
const colorMap = { amber:'#d97706', blue:'#2563eb', green:'#059669', violet:'#7c3aed', pink:'#db2777' };
async function refresh() {
const tbSlug = tbSel.value;
const classId = clsSel.value;
if (!tbSlug || !classId) return;
resEl.className = 'tb-empty';
resEl.innerHTML = 'Загрузка…';
try {
const r = await LS.api(`/api/textbooks/${tbSlug}/class-progress?class_id=${classId}`);
const total = r.total_paragraphs || 0;
const tb = textbooks.find(t => t.slug === tbSlug);
const color = colorMap[tb?.color] || '#7c3aed';
if (!r.students.length) {
resEl.innerHTML = '<div class="tb-empty">В классе нет учеников</div>'; return;
}
r.students.sort((a, b) => (b.read_count - a.read_count) || a.name.localeCompare(b.name));
const rows = r.students.map(s => {
const pct = total > 0 ? Math.round(100 * s.read_count / total) : 0;
return `
<div class="tp-row">
<div class="tp-name">${esc(s.name)}</div>
<div>
<div class="tp-bar"><div class="tp-bar-fill" style="width:${pct}%;background:${color}"></div></div>
<div class="tp-bar-text">${s.read_count} из ${total} §</div>
</div>
<div><b style="color:var(--text);font-family:'Unbounded',sans-serif">${pct}%</b></div>
<div class="tp-last">${s.last_para ? `<b>§${s.last_para.replace('p','')}</b><br><small>${fmtDate(s.last_at)}</small>` : '<small>—</small>'}</div>
</div>`;
}).join('');
resEl.className = 'tp-table';
resEl.innerHTML = `
<div class="tp-row head">
<div>Ученик</div><div>Прогресс</div><div>%</div><div>Последний §</div>
</div>${rows}`;
} catch (e) {
resEl.className = 'tb-empty';
resEl.innerHTML = 'Ошибка: ' + esc(e.message);
}
}
tbSel.addEventListener('change', refresh);
clsSel.addEventListener('change', refresh);
refresh();
}
/* ════════════════════════════════════════════════
TAB: Manage textbooks (admin only)
════════════════════════════════════════════════ */
const SUBJECTS = { chemistry:'Химия', physics:'Физика', math:'Математика', biology:'Биология' };
let allTextbooks = [];
async function initManageTab() {
const el = document.getElementById('am-content');
try {
const r = await LS.api('/api/textbooks/admin/all');
allTextbooks = r.textbooks || [];
renderManage();
} catch (e) {
el.innerHTML = 'Ошибка: ' + esc(e.message);
}
}
function renderManage() {
const el = document.getElementById('am-content');
if (!allTextbooks.length) {
el.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>'; return;
}
const html = `
<div class="tb-list" style="background:var(--surface);border:1.5px solid var(--border);border-radius:14px;overflow:hidden">
<div class="am-row head">
<div>Учебник</div><div>Автор</div><div>Предмет</div>
<div>Класс</div><div>Читателей</div><div>Активен</div>
</div>
${allTextbooks.map(t => `
<div class="am-row ${t.is_active ? '' : 'inactive'}" data-id="${t.id}">
<div>
<input class="am-input" data-field="title" value="${esc(t.title)}" />
<div style="margin-top:4px">
<a class="am-link" href="/textbook/${t.slug}" target="_blank">/${t.slug}</a>
<span class="am-saved" id="am-saved-${t.id}">Сохранено</span>
</div>
</div>
<div><input class="am-input" data-field="author" value="${esc(t.author)}" /></div>
<div><span class="am-pill">${esc(SUBJECTS[t.subject] || t.subject)}</span></div>
<div>${t.grade}</div>
<div>${t.readers || 0}</div>
<div><span class="am-toggle ${t.is_active ? 'on' : ''}" data-field="is_active"></span></div>
</div>`).join('')}
</div>`;
el.className = '';
el.innerHTML = html;
wireManageEvents();
}
function wireManageEvents() {
document.querySelectorAll('#am-content .am-toggle').forEach(t => {
t.addEventListener('click', async () => {
const row = t.closest('.am-row');
const id = Number(row.dataset.id);
const newVal = t.classList.contains('on') ? 0 : 1;
try {
await LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { is_active: newVal } });
t.classList.toggle('on', newVal === 1);
row.classList.toggle('inactive', !newVal);
flashManageSaved(id);
} catch (e) { alert(e.message); }
});
});
document.querySelectorAll('#am-content .am-input').forEach(inp => {
let timer;
inp.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const row = inp.closest('.am-row');
const id = Number(row.dataset.id);
LS.api('/api/textbooks/admin/' + id, { method: 'PATCH', body: { [inp.dataset.field]: inp.value } })
.then(() => flashManageSaved(id))
.catch(e => alert(e.message));
}, 600);
});
});
}
function flashManageSaved(id) {
const el = document.getElementById('am-saved-' + id);
if (!el) return;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1500);
}
await loadTextbooks();
})();
</script>
</body>
</html>