Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov 91696ba089 refactor: textbooks assign modal → LS.modal (−120 строк)
Та же миграция что и в exam9: убран inline-overlay HTML, дубликаты
CSS (.ex-overlay/.ex-panel/.ex-panel-* + .ax-error/.ax-success/
.ax-actions/.ax-btn) — всё это теперь .ls-mod-* из LS.modal.

Глобальные window.openAssignModal/closeAssignModal/onAssignOverlayClick/
onAssignEsc/setAssignTab/submitAssign и assignSlug/assignTitle/assignTab
переменные заменены на одну window.openAssignModal с локальным
closure по slug/title/currentTab.

Сохранены внутренние form-классы (.ax-form/.ax-classes/.ax-class/
.ax-tabs/.ax-tab/.ax-student-results/.ax-input/.ax-hint) — они
используются в body модалки.

Student search и tab-switching теперь обработчики на элементах
модалки (m.body.querySelector), а не глобальные document-listener'ы —
автоматически очищаются вместе с модалкой при close().

textbooks.html: 945 → 824 строки
2026-05-16 18:51:58 +03:00

825 lines
35 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) ── */
.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-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>`;
}
}
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 (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' };
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>