Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov 40df8893cc fix(lab): значок «связанной симуляции» на карточках учебников не скрывался при выключенной лаборатории
В каталоге учебников (textbooks.html) у карточек есть кнопка .tb-lab-btn «открыть
связанную симуляцию» (openLabSim → /lab?sim=…). Это <button onclick>, а не <a href="/lab">,
поэтому kill-switch `[href="/lab"]` её не ловил, и значок-колба оставался при отключённой
«Лаборатории».

Фикс: добавил `.tb-lab-btn` в FEATURE_WIDGETS.lab → api.js скрывает её через инъекцию
при lab=false (работает и без ls.css). Плюс страховка в openLabSim: при lab=false не
открываем (тост «Лаборатория отключена»); админ — всегда (admin-override).

Verified vm-смоук на реальном api.js 4/4 (lab off → .tb-lab-btn скрыта; lab on → нет;
admin → ничего). node --check api.js + инлайн textbooks.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:36:11 +03:00

884 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.indigo { background:linear-gradient(135deg, #3730a3 0%, #4f46e5 60%, #818cf8 100%); }
.tb-cover.rose { background:linear-gradient(135deg, #9f1239 0%, #e11d48 60%, #fb7185 100%); }
.tb-cover.teal { background:linear-gradient(135deg, #134e4a 0%, #0d9488 60%, #14b8a6 100%); }
.tb-cover.cyan { background:linear-gradient(135deg, #164e63 0%, #0891b2 60%, #22d3ee 100%); }
.tb-cover.emerald{ background:linear-gradient(135deg, #064e3b 0%, #059669 60%, #34d399 100%); }
.tb-cover.amber-light{ background:linear-gradient(135deg, #92400e 0%, #d97706 60%, #fbbf24 100%); }
.tb-cover.sky { background:linear-gradient(135deg, #0c4a6e 0%, #0284c7 60%, #7dd3fc 100%); }
.tb-cover.red { background:linear-gradient(135deg, #7f1d1d 0%, #dc2626 60%, #f87171 100%); }
.tb-cover.orange { background:linear-gradient(135deg, #9a3412 0%, #ea580c 60%, #fb923c 100%); }
.tb-cover.yellow { background:linear-gradient(135deg, #854d0e 0%, #ca8a04 60%, #fde047 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.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.sky .tb-progress-fill { background:#0284c7; }
.tb-progress.red .tb-progress-fill { background:#dc2626; }
.tb-progress.orange .tb-progress-fill { background:#ea580c; }
.tb-progress.yellow .tb-progress-fill { background:#ca8a04; }
.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.indigo { background:#4f46e5; }
.tb-btn.primary.rose { background:#e11d48; }
.tb-btn.primary.teal { background:#0d9488; }
.tb-btn.primary.cyan { background:#0891b2; }
.tb-btn.primary.emerald { background:#059669; }
.tb-btn.primary.sky { background:#0284c7; }
.tb-btn.primary.red { background:#dc2626; }
.tb-btn.primary.orange { background:#ea580c; }
.tb-btn.primary.yellow { background:#ca8a04; }
.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-lab-btn {
width:auto; min-width:42px; padding:9px 12px;
flex:0 0 auto; white-space:nowrap;
font-variant-numeric: tabular-nums; font-weight:800;
}
.tb-lab-btn svg { stroke:var(--violet); flex-shrink:0; }
.tb-lab-btn:hover { border-color:var(--violet); background:rgba(155,93,229,.08); }
.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]));
}
let labLinks = {}; // { textbook_slug: [{id,title,cat}] } — связанные симуляции (Фаза 5)
async function loadTextbooks() {
try {
const [r, labRes] = await Promise.all([
LS.api('/api/textbooks'),
LS.api('/api/lab/links/all?kind=textbook').catch(() => ({ byRef: {} })),
]);
textbooks = r.textbooks || [];
labLinks = (labRes && labRes.byRef) || {};
render();
} catch (e) {
document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`;
}
}
/* Фаза 5: открыть связанную симуляцию из карточки учебника (не уходя в учебник). */
function openLabSim(simId, ev) {
if (ev) ev.stopPropagation();
// Страховка: если «Лаборатория» отключена — не открываем (кнопка и так скрыта
// kill-switch'ем). Админ имеет доступ всегда (admin-override).
try {
const u = LS.getUser && LS.getUser();
if (!(u && u.role === 'admin')) {
const f = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null');
if (f && f.lab === false) { if (LS.toast) LS.toast('Лаборатория отключена', 'warn'); return; }
}
} catch (e) { /* нет кэша — открываем как раньше */ }
location.href = '/lab?sim=' + encodeURIComponent(simId);
}
window.openLabSim = openLabSim;
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>` : ''}
${(labLinks[t.slug] && labLinks[t.slug].length) ? `<button class="tb-btn tb-lab-btn" onclick="openLabSim('${esc(labLinks[t.slug][0].id)}', event)" title="В лабораторию${labLinks[t.slug].length > 1 ? ' — связанных симуляций: ' + labLinks[t.slug].length : ''}" aria-label="В лабораторию">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg>${labLinks[t.slug].length > 1 ? labLinks[t.slug].length : ''}
</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', 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>