Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov f08a81263d refactor(textbooks ui): компактная сетка каталога — плитки 190px + фильтры по предмету
- Карточка: горизонтальный layout, 74px высоты — цветная маркер-полоса слева (46px) с классом + 4-буквенной аббрев. предмета + watermark, справа название/счётчик параграфов/прогресс-бар (3px)
- В ряд помещается 5-7 карточек на десктопе (вместо 2-3)
- Вся карточка кликабельна (ведёт на 'Продолжить' или 'Открыть')
- Кнопка 'Назначить ДЗ' для учителя — overlay в углу, появляется на hover
- Сверху сетки чипсы-фильтры по предмету с счётчиком; скрыты, если предметов <2
- На hover чуть приподнимается, в углу появляется 'Продолжить →'
- Mobile: 160px минимум, узкие отступы
2026-05-29 21:39:55 +03:00

910 lines
39 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; }
/* ── Filter chips ── */
.tb-filters {
display:flex; gap:6px; flex-wrap:wrap; margin-bottom:16px; align-items:center;
}
.tb-filter-label {
font-size:.7rem; font-weight:700; color:var(--text-3);
text-transform:uppercase; letter-spacing:.06em; margin-right:6px;
}
.tb-chip {
padding:5px 11px; border-radius:99px;
background:var(--surface); border:1.5px solid var(--border);
color:var(--text-2); font-family:'Manrope',sans-serif;
font-size:.78rem; font-weight:700; cursor:pointer;
transition:all .14s; display:inline-flex; align-items:center; gap:5px;
}
.tb-chip:hover { color:var(--text); border-color:var(--text-3); }
.tb-chip.active {
background:var(--violet); color:#fff; border-color:var(--violet);
}
.tb-chip-count {
font-size:.68rem; opacity:.7; font-weight:600;
}
/* ── Compact grid ── */
.tb-grid {
display:grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap:10px;
}
.tb-card {
position:relative;
background:var(--surface);
border:1.5px solid var(--border);
border-radius:12px; overflow:hidden;
display:flex; align-items:stretch;
text-decoration:none; color:inherit;
transition: border-color .14s, transform .14s, box-shadow .14s;
min-height:74px;
}
.tb-card:hover {
transform: translateY(-1px);
border-color: var(--text-3);
box-shadow: 0 4px 14px rgba(0,0,0,.08);
}
/* Left color strip (cover-marker) */
.tb-mark {
flex:0 0 46px;
display:flex; flex-direction:column;
align-items:center; justify-content:center;
color:#fff; position:relative; overflow:hidden;
}
.tb-mark::before {
content: attr(data-watermark);
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
font-family:'Unbounded',sans-serif; font-weight:900;
font-size:2.4rem; letter-spacing:-.04em; line-height:1;
color:rgba(255,255,255,.16);
pointer-events:none; user-select:none; z-index:0;
}
.tb-mark-grade {
position:relative; z-index:1;
font-family:'Unbounded',sans-serif; font-weight:800;
font-size:1.1rem; letter-spacing:-.02em; line-height:1;
}
.tb-mark-sub {
position:relative; z-index:1;
font-size:.56rem; font-weight:800; text-transform:uppercase;
letter-spacing:.08em; margin-top:3px; opacity:.85;
}
.tb-mark.amber { background:linear-gradient(160deg, #b45309 0%, #f59e0b 100%); }
.tb-mark.blue { background:linear-gradient(160deg, #1e40af 0%, #3b82f6 100%); }
.tb-mark.green { background:linear-gradient(160deg, #047857 0%, #10b981 100%); }
.tb-mark.violet { background:linear-gradient(160deg, #6d28d9 0%, #9333ea 100%); }
.tb-mark.pink { background:linear-gradient(160deg, #be185d 0%, #ec4899 100%); }
.tb-mark.indigo { background:linear-gradient(160deg, #3730a3 0%, #818cf8 100%); }
.tb-mark.rose { background:linear-gradient(160deg, #9f1239 0%, #fb7185 100%); }
.tb-mark.teal { background:linear-gradient(160deg, #134e4a 0%, #14b8a6 100%); }
.tb-mark.cyan { background:linear-gradient(160deg, #164e63 0%, #22d3ee 100%); }
.tb-mark.emerald{ background:linear-gradient(160deg, #064e3b 0%, #34d399 100%); }
.tb-mark.amber-light{ background:linear-gradient(160deg, #92400e 0%, #fbbf24 100%); }
/* Body */
.tb-body {
flex:1; min-width:0;
padding:9px 12px 8px 12px;
display:flex; flex-direction:column; gap:5px;
justify-content:center;
}
.tb-title-row {
display:flex; align-items:flex-start; gap:6px;
}
.tb-name {
font-family:'Manrope',sans-serif; font-weight:700;
font-size:.86rem; line-height:1.25; color:var(--text);
overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical;
flex:1;
}
.tb-meta {
font-size:.68rem; color:var(--text-3); font-weight:600;
display:flex; align-items:center; gap:6px;
}
.tb-meta b { color:var(--text-2); font-weight:700; }
/* Progress strip */
.tb-progress {
display:flex; align-items:center; gap:6px;
margin-top:auto;
}
.tb-progress-bar {
flex:1; height:3px; border-radius:99px; background:var(--border); overflow:hidden;
}
.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.indigo .tb-progress-fill { background:#4f46e5; }
.tb-progress.rose .tb-progress-fill { background:#e11d48; }
.tb-progress.teal .tb-progress-fill { background:#0d9488; }
.tb-progress.cyan .tb-progress-fill { background:#0891b2; }
.tb-progress.emerald .tb-progress-fill { background:#059669; }
.tb-progress-text {
font-size:.66rem; color:var(--text-3); font-weight:700;
font-variant-numeric: tabular-nums;
min-width:28px; text-align:right;
}
/* Teacher assign button — overlay on hover */
.tb-assign-btn {
position:absolute; top:5px; right:5px;
width:24px; height:24px; padding:0; border-radius:6px;
background:rgba(255,255,255,.92); border:1px solid var(--border);
color:var(--text-2); cursor:pointer;
display:flex; align-items:center; justify-content:center;
opacity:0; transition:opacity .14s, color .14s, transform .14s;
z-index:2;
}
html.dark .tb-assign-btn { background:rgba(20,20,25,.92); }
.tb-card:hover .tb-assign-btn { opacity:1; }
.tb-assign-btn:hover { color:var(--violet); transform:scale(1.05); }
.tb-assign-btn svg { width:13px; height:13px; }
/* Continue badge (small "play" overlay on cards in progress) */
.tb-resume {
position:absolute; bottom:5px; right:5px;
font-size:.6rem; font-weight:800; color:var(--text-3);
text-transform:uppercase; letter-spacing:.04em;
opacity:0; transition:opacity .14s;
}
.tb-card:hover .tb-resume { opacity:1; }
.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); }
@media (max-width: 600px) {
.tb-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap:8px; }
.tb-name { font-size:.8rem; }
.tb-mark { flex-basis:40px; }
}
/* ── 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) ── */
.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-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-filters" id="tb-filters" style="display:none">
<span class="tb-filter-label">Предмет</span>
<button class="tb-chip active" data-subject="all" onclick="setSubjectFilter('all')">Все <span class="tb-chip-count" id="cnt-all"></span></button>
<span id="tb-subject-chips"></span>
</div>
<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>
<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>`;
}
}
let subjectFilter = 'all';
const SUBJECT_LABELS = {
physics:'Физика', math:'Математика', chemistry:'Химия', biology:'Биология',
russian:'Русский', literature:'Литература', english:'Английский',
history:'История', geography:'География', informatics:'Информатика'
};
const SUBJECT_WATERMARK = {
physics:'Φ', math:'Σ', chemistry:'Х', biology:'Β',
russian:'Р', literature:'Л', english:'E',
history:'H', geography:'G', informatics:'I'
};
window.setSubjectFilter = function(s) {
subjectFilter = s;
document.querySelectorAll('.tb-chip').forEach(c => c.classList.toggle('active', c.dataset.subject === s));
render();
};
function buildSubjectChips() {
const counts = {};
textbooks.forEach(t => { counts[t.subject] = (counts[t.subject] || 0) + 1; });
const subjects = Object.keys(counts).sort();
if (!subjects.length || subjects.length < 2) {
document.getElementById('tb-filters').style.display = 'none';
return;
}
document.getElementById('tb-filters').style.display = 'flex';
document.getElementById('cnt-all').textContent = textbooks.length;
const cont = document.getElementById('tb-subject-chips');
cont.innerHTML = subjects.map(s => {
const label = SUBJECT_LABELS[s] || s;
return `<button class="tb-chip" data-subject="${esc(s)}" onclick="setSubjectFilter('${esc(s)}')">${esc(label)} <span class="tb-chip-count">${counts[s]}</span></button>`;
}).join('');
}
function render() {
const grid = document.getElementById('tb-grid');
if (!textbooks.length) {
grid.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>';
return;
}
buildSubjectChips();
const filtered = subjectFilter === 'all' ? textbooks : textbooks.filter(t => t.subject === subjectFilter);
if (!filtered.length) {
grid.innerHTML = '<div class="tb-empty">По этому фильтру ничего нет</div>';
return;
}
grid.innerHTML = filtered.map(t => {
const readCount = (t.progress?.read || []).length;
const pct = t.para_count ? Math.round(100 * readCount / t.para_count) : 0;
const watermark = SUBJECT_WATERMARK[t.subject] || '§';
const continueHref = t.progress?.last_para
? `/textbook/${t.slug}#${t.progress.last_para}`
: `/textbook/${t.slug}`;
const resumeLabel = t.progress?.last_para ? 'Продолжить →' : 'Открыть →';
const subjLabel = SUBJECT_LABELS[t.subject] || t.subject || '';
return `
<a class="tb-card" href="${continueHref}" title="${esc(t.title)}\n${esc(t.description || '')}">
<div class="tb-mark ${t.color}" data-watermark="${watermark}">
<div class="tb-mark-grade">${t.grade}</div>
<div class="tb-mark-sub">${esc(subjLabel.slice(0,4)).toLowerCase()}</div>
</div>
<div class="tb-body">
<div class="tb-title-row">
<div class="tb-name">${esc(t.title)}</div>
</div>
<div class="tb-meta">
<span><b>${readCount}</b>/${t.para_count || '?'} §</span>
${t.author ? `<span style="opacity:.6">·</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0">${esc(t.author.split(' ').pop())}</span>` : ''}
</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">${pct}%</div>
</div>
</div>
${isTeacher ? `<button class="tb-assign-btn" onclick="event.preventDefault();event.stopPropagation();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 class="tb-resume">${resumeLabel}</div>
</a>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
/* ── Assign modal (via LS.modal) ── */
let teacherStudents = 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;
}
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.openAssignModal = async function (slug, title) {
const classes = await loadTeacherClasses();
const classesHtml = classes.length
? 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('')
: '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
const body = `
<form class="ax-form" onsubmit="event.preventDefault()">
<div class="ax-field">
<label>Кому</label>
<div class="ax-tabs">
<button type="button" class="ax-tab active" data-tab="class">Классу</button>
<button type="button" class="ax-tab" data-tab="student">Ученику</button>
</div>
</div>
<div class="ax-field" data-pane="class">
<label>Классы</label>
<div class="ax-classes">${classesHtml}</div>
</div>
<div class="ax-field" data-pane="student" style="display:none">
<label>Ученик</label>
<input type="text" class="ax-input" name="student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
<div class="ax-student-results"></div>
<input type="hidden" name="student-id" />
</div>
<div class="ax-field">
<label>Параграфы</label>
<input type="text" class="ax-input" name="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" name="deadline" />
</div>
</form>`;
let currentTab = 'class';
const m = LS.modal({
title: `Назначить чтение: «${title}»`,
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => m.close() },
{
label: 'Назначить', primary: true,
onClick: async () => {
const f = m.body.querySelector('form');
const paragraphs = f['paragraphs'].value.trim();
const deadline = f['deadline'].value || null;
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
const btns = m.root.querySelectorAll('.ls-mod-btn');
btns.forEach(b => b.disabled = true);
btns[1].textContent = 'Назначаю…';
try {
if (currentTab === 'class') {
const checked = [...f.querySelectorAll('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: `Учебник: ${title}${titleSuffix}`,
class_ids: checked,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline, textbook_slug: slug, textbook_paragraphs: paragraphs || null,
},
});
LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
} else {
const studentId = Number(f['student-id'].value);
if (!studentId) throw new Error('Выберите ученика');
await LS.api('/api/assignments', {
method: 'POST',
body: {
title: `Учебник: ${title}${titleSuffix}`,
student_id: studentId,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline, textbook_slug: slug, textbook_paragraphs: paragraphs || null,
},
});
LS.toast('Личное задание создано', 'success');
}
m.close();
} catch (e) {
m.setError(e.message || 'Не удалось создать задание');
btns.forEach(b => b.disabled = false);
btns[1].textContent = 'Назначить';
}
},
},
],
});
// Tab switching within modal
m.body.querySelectorAll('.ax-tab').forEach(tab => {
tab.addEventListener('click', () => {
currentTab = tab.dataset.tab;
m.body.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t === tab));
m.body.querySelectorAll('[data-pane]').forEach(p => p.style.display = p.dataset.pane === currentTab ? '' : 'none');
});
});
// Student search (debounced, scoped to this modal)
let stTimer = null;
const searchInput = m.body.querySelector('input[name="student-search"]');
const resultsEl = m.body.querySelector('.ax-student-results');
const idInput = m.body.querySelector('input[name="student-id"]');
searchInput.addEventListener('input', () => {
clearTimeout(stTimer);
stTimer = setTimeout(async () => {
const q = searchInput.value.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);
resultsEl.innerHTML = matches.length
? 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('')
: '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
resultsEl.classList.add('visible');
}, 200);
});
resultsEl.addEventListener('click', e => {
const row = e.target.closest('.ax-student-row');
if (!row || !row.dataset.id) return;
m.body.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
idInput.value = row.dataset.id;
searchInput.value = row.dataset.name;
resultsEl.classList.remove('visible');
});
};
/* ════════════════════════════════════════════════
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', indigo:'#4f46e5', rose:'#e11d48' };
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>