40df8893cc
В каталоге учебников (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>
884 lines
40 KiB
HTML
884 lines
40 KiB
HTML
<!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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|