Files
Learn_System/frontend/textbooks.html
T
Maxim Dolgolyov 3ff2f01178 feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
A1 — карточка ДЗ-чтения у ученика на /dashboard:
  - Новая ветка в buildAssignCard для assignments с textbook_id
  - Прогресс-бар «X из Y §», цвет берётся из textbook.color
  - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф
  - В classify(): textbook_all_read → done, deadline → overdue

A2 — авто-проверка выполнения:
  - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments
  - Если все требуемые § прочитаны → INSERT в assignment_completion
  - SSE-уведомление учителю «Ученик завершил чтение: <title>»
  - myAssignments возвращает completed_at и textbook_all_read

A3 — учительский UI прогресса класса:
  - Новая страница /textbook-progress (учитель/админ)
  - Селекторы «учебник × класс» → таблица учеников с прогрессом
  - Сортировка по количеству прочитанного, дата last_at
  - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям)

B4 — admin-UI управления учебниками:
  - /admin-textbooks (только admin) — таблица всех учебников
  - Inline-редактирование title/author, тоггл is_active
  - Колонка «Читателей» (count из textbook_progress)
  - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id

C7 — закладки/заметки внутри учебника:
  - Таблица textbook_bookmarks (user, textbook, para, text, note, color)
  - API: GET/POST/PATCH/DELETE для CRUD закладок
  - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка»
  - Кнопка-иконка в overlay top-left открывает панель «Мои закладки»
  - Хранится paragraph-якорь, цвет, заметка, кнопка удалить

Назначение ученику (в дополнение к классу):
  - В модалке /textbooks — переключатель «Классу / Ученику»
  - Поиск ученика по имени/email через /api/classes/students
  - Submit использует POST /api/assignments (createDirectAssignment)
  - createDirectAssignment расширен textbook_slug + textbook_paragraphs
  - Учитель может назначать только ученикам своих классов

myAssignments расширен: возвращает textbook fields + post-process
  считает textbook_required_count, textbook_read_count, textbook_all_read.

Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange
вызывает setParaTab(pN) (нативная функция учебника).

Миграция 005: assignment_completion + textbook_bookmarks + индексы.
2026-05-16 16:37:11 +03:00

588 lines
25 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); }
/* ── Assign modal (reused styling from exam9) ── */
.ex-overlay {
display:none; position:fixed; inset:0;
background:rgba(15,23,42,.55); z-index:300;
align-items:flex-start; justify-content:center; padding-top:80px;
backdrop-filter:blur(2px);
}
.ex-overlay.visible { display:flex; }
.ex-panel {
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.32);
width:min(520px, 94vw); max-height:calc(100vh - 120px);
overflow-y:auto; padding:22px 22px 26px;
}
.ex-panel-head {
display:flex; align-items:center; justify-content:space-between; margin-bottom:18px;
}
.ex-panel-head h2 { font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:800; }
.ex-panel-close {
width:32px; height:32px; border:none; background:none;
color:var(--text-2); cursor:pointer; border-radius:8px;
display:flex; align-items:center; justify-content:center; transition:background .15s;
}
.ex-panel-close:hover { background:var(--border); color:var(--text); }
.ex-panel-close svg { width:18px; height:18px; }
.ax-form { display:flex; flex-direction:column; gap:14px; }
.ax-field label {
display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
text-transform:uppercase; letter-spacing:.05em; margin-bottom:6px;
}
.ax-classes {
display:flex; flex-direction:column; gap:6px; max-height:200px; overflow-y:auto;
border:1.5px solid var(--border); border-radius:10px; padding:8px;
}
.ax-class {
display:flex; align-items:center; gap:10px; padding:8px 10px;
border-radius:8px; cursor:pointer; transition:background .12s;
font-size:.9rem;
}
.ax-class:hover { background:var(--border); }
.ax-class input { accent-color:var(--violet); flex-shrink:0; }
.ax-class .ax-cname { font-weight:600; }
.ax-class .ax-cmeta { font-size:.78rem; color:var(--text-3); margin-left:auto; }
.ax-input {
width:100%; padding:9px 12px; border:1.5px solid var(--border-h);
border-radius:9px; background:var(--surface); color:var(--text);
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ax-input:focus { outline:none; border-color:var(--violet); }
.ax-hint { font-size:.74rem; color:var(--text-3); margin-top:4px; }
.ax-actions { display:flex; gap:10px; justify-content:flex-end; margin-top:6px; }
.ax-btn {
padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
background:transparent; color:var(--text);
font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
cursor:pointer; transition:all .15s;
}
.ax-btn:hover { border-color:var(--text-2); }
.ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
.ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
.ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
.ax-error, .ax-success {
padding:9px 12px; border-radius:8px; font-size:.84rem; display:none;
}
.ax-error.visible { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
.ax-success.visible { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
.ax-tabs { display:flex; gap:6px; background:var(--border); padding:4px; border-radius:10px; }
.ax-tab {
flex:1; padding:7px 12px; border-radius:7px;
border:none; background:transparent; color:var(--text-2);
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
cursor:pointer; transition:all .12s;
}
.ax-tab:hover { color:var(--text); }
.ax-tab.active { background:var(--surface); color:var(--violet); box-shadow:0 1px 4px rgba(0,0,0,.08); }
.ax-student-results {
margin-top:6px; max-height:160px; overflow-y:auto;
border:1.5px solid var(--border); border-radius:10px;
display:none;
}
.ax-student-results.visible { display:block; }
.ax-student-row {
padding:8px 12px; cursor:pointer; transition:background .12s;
display:flex; align-items:center; gap:10px;
font-size:.85rem;
}
.ax-student-row:hover { background:var(--border); }
.ax-student-row.selected { background:rgba(155,93,229,.12); color:var(--violet); }
.ax-student-row .ax-student-email { font-size:.75rem; color:var(--text-3); margin-left:auto; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="tb-wrap">
<header class="tb-header">
<div class="tb-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<line x1="9" y1="7" x2="15" y2="7"/>
<line x1="9" y1="11" x2="15" y2="11"/>
</svg>
</div>
<div style="flex:1">
<div class="tb-title">Учебники</div>
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
</div>
<div id="tb-header-actions"></div>
</header>
<div class="tb-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>
</div>
</div>
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
<div class="ex-panel" onclick="event.stopPropagation()">
<div class="ex-panel-head">
<h2 id="assign-title">Назначить чтение</h2>
<button class="ex-panel-close" onclick="closeAssignModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
<div class="ax-field">
<label>Кому</label>
<div class="ax-tabs">
<button type="button" class="ax-tab active" data-tab="class" onclick="setAssignTab('class')">Классу</button>
<button type="button" class="ax-tab" data-tab="student" onclick="setAssignTab('student')">Ученику</button>
</div>
</div>
<div class="ax-field" id="ax-class-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field" id="ax-student-field" style="display:none">
<label>Ученик</label>
<input type="text" class="ax-input" id="ax-student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
<div class="ax-student-results" id="ax-student-results"></div>
<input type="hidden" id="ax-student-id" />
</div>
<div class="ax-field">
<label>Параграфы</label>
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
</div>
<div class="ax-field">
<label>Срок сдачи</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
<div class="ax-error" id="ax-error"></div>
<div class="ax-success" id="ax-success"></div>
<div class="ax-actions">
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<script>
(async function () {
const user = LS.initPage();
LS.showBoardIfAllowed();
LS.hideDisabledFeatures();
const isTeacher = user && (user.role === 'teacher' || user.role === 'admin');
let textbooks = [];
let teacherClasses = null;
// Teacher-only: "Class progress" button in header
if (isTeacher) {
document.getElementById('tb-header-actions').innerHTML = `
<a href="/textbook-progress" class="tb-btn" style="display:inline-flex;width:auto;text-decoration:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
Прогресс класса
</a>`;
}
function esc(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
async function loadTextbooks() {
try {
const r = await LS.api('/api/textbooks');
textbooks = r.textbooks || [];
render();
} catch (e) {
document.getElementById('tb-grid').innerHTML = `<div class="tb-empty">Не удалось загрузить: ${esc(e.message)}</div>`;
}
}
function render() {
const grid = document.getElementById('tb-grid');
if (!textbooks.length) {
grid.innerHTML = '<div class="tb-empty">Учебники не добавлены</div>';
return;
}
grid.innerHTML = textbooks.map(t => {
const readCount = (t.progress?.read || []).length;
const pct = t.para_count ? Math.round(100 * readCount / t.para_count) : 0;
const watermark = t.subject === 'chemistry' ? 'Х' : t.subject === 'physics' ? 'Φ' : t.subject === 'math' ? 'Σ' : t.subject === 'biology' ? 'Β' : '§';
const continueHref = t.progress?.last_para
? `/textbook/${t.slug}#${t.progress.last_para}`
: `/textbook/${t.slug}`;
return `
<article class="tb-card">
<div class="tb-cover ${t.color}" data-watermark="${watermark}">
<div class="tb-cover-info">
<div class="tb-cover-grade">${t.grade} класс</div>
<div class="tb-cover-title">${esc(t.title)}</div>
</div>
</div>
<div class="tb-body">
${t.author ? `<div class="tb-author">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
${esc(t.author)}
</div>` : ''}
<div class="tb-desc">${esc(t.description)}</div>
<div class="tb-progress ${t.color}">
<div class="tb-progress-bar">
<div class="tb-progress-fill" style="width:${pct}%"></div>
</div>
<div class="tb-progress-text">
<span><b>${readCount}</b> из ${t.para_count} прочитано</span>
<span>${pct}%</span>
</div>
</div>
<div class="tb-actions">
<a href="${continueHref}" class="tb-btn primary ${t.color}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
${t.progress?.last_para ? 'Продолжить' : 'Открыть'}
</a>
${isTeacher ? `<button class="tb-btn tb-assign-btn" onclick="openAssignModal('${t.slug}', '${esc(t.title)}')" title="Назначить чтение как ДЗ">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M2 12h20"/></svg>
</button>` : ''}
</div>
</div>
</article>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
/* ── Assign modal ── */
let assignSlug = null;
let assignTitle = null;
let assignTab = 'class'; // 'class' or 'student'
let teacherStudents = null; // cached list of students-in-teacher's-classes
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
try {
const list = await LS.api('/api/classes');
teacherClasses = Array.isArray(list) ? list : [];
} catch { teacherClasses = []; }
return teacherClasses;
}
async function loadTeacherStudents() {
if (teacherStudents) return teacherStudents;
try {
const r = await LS.api('/api/classes/students');
teacherStudents = Array.isArray(r) ? r : (r.students || []);
} catch { teacherStudents = []; }
return teacherStudents;
}
window.setAssignTab = function (tab) {
assignTab = tab;
document.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('ax-class-field').style.display = tab === 'class' ? '' : 'none';
document.getElementById('ax-student-field').style.display = tab === 'student' ? '' : 'none';
document.getElementById('ax-student-id').value = '';
document.getElementById('ax-student-search').value = '';
document.getElementById('ax-student-results').classList.remove('visible');
};
window.openAssignModal = async function (slug, title) {
assignSlug = slug;
assignTitle = title;
document.getElementById('assign-title').textContent = `Назначить чтение: «${title}»`;
['ax-error', 'ax-success'].forEach(id => document.getElementById(id).classList.remove('visible'));
document.getElementById('ax-paragraphs').value = '';
document.getElementById('ax-deadline').value = '';
document.getElementById('ax-submit').disabled = false;
document.getElementById('ax-submit').textContent = 'Назначить';
setAssignTab('class');
const listEl = document.getElementById('ax-classes-list');
listEl.textContent = 'Загрузка…';
const classes = await loadTeacherClasses();
if (!classes.length) {
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
} else {
listEl.innerHTML = classes.map(c => `
<label class="ax-class">
<input type="checkbox" name="cls" value="${c.id}" />
<span class="ax-cname">${esc(c.name)}</span>
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
</label>`).join('');
}
document.getElementById('assign-overlay').classList.add('visible');
document.addEventListener('keydown', onAssignEsc);
};
window.closeAssignModal = function () {
document.getElementById('assign-overlay').classList.remove('visible');
document.removeEventListener('keydown', onAssignEsc);
};
window.onAssignOverlayClick = function (e) {
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
};
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
/* Student search (debounced) */
let stSearchTimer = null;
document.addEventListener('input', e => {
if (e.target?.id !== 'ax-student-search') return;
clearTimeout(stSearchTimer);
stSearchTimer = setTimeout(() => filterStudents(e.target.value), 200);
});
async function filterStudents(q) {
const resultsEl = document.getElementById('ax-student-results');
q = q.trim().toLowerCase();
if (q.length < 2) { resultsEl.classList.remove('visible'); return; }
const students = await loadTeacherStudents();
const matches = students.filter(s =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.email && s.email.toLowerCase().includes(q))
).slice(0, 12);
if (!matches.length) {
resultsEl.innerHTML = '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
} else {
resultsEl.innerHTML = matches.map(s => `
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
<span>${esc(s.name)}</span>
<span class="ax-student-email">${esc(s.email || '')}</span>
</div>`).join('');
}
resultsEl.classList.add('visible');
}
document.addEventListener('click', e => {
const row = e.target.closest('.ax-student-row');
if (!row || !row.dataset.id) return;
document.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
document.getElementById('ax-student-id').value = row.dataset.id;
document.getElementById('ax-student-search').value = row.dataset.name;
document.getElementById('ax-student-results').classList.remove('visible');
});
window.submitAssign = async function () {
const errorEl = document.getElementById('ax-error');
const successEl = document.getElementById('ax-success');
const submitBtn = document.getElementById('ax-submit');
errorEl.classList.remove('visible');
successEl.classList.remove('visible');
const paragraphs = document.getElementById('ax-paragraphs').value.trim();
const deadline = document.getElementById('ax-deadline').value || null;
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
submitBtn.disabled = true;
submitBtn.textContent = 'Назначаю…';
try {
let resultMsg;
if (assignTab === 'class') {
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
.map(el => Number(el.value));
if (!checked.length) throw new Error('Выберите хотя бы один класс');
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
class_ids: checked,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
resultMsg = `Назначено в ${r.count || checked.length} класс(е/ах)`;
} else {
const studentId = Number(document.getElementById('ax-student-id').value);
if (!studentId) throw new Error('Выберите ученика');
await LS.api('/api/assignments', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
student_id: studentId,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
resultMsg = 'Личное задание создано';
}
successEl.textContent = resultMsg;
successEl.classList.add('visible');
submitBtn.textContent = 'Готово';
setTimeout(closeAssignModal, 1500);
} catch (e) {
errorEl.textContent = e.message || 'Не удалось создать задание';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Назначить';
}
};
await loadTextbooks();
})();
</script>
</body>
</html>