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 + индексы.
This commit is contained in:
Maxim Dolgolyov
2026-05-16 16:37:11 +03:00
parent e8018d85c1
commit 3ff2f01178
8 changed files with 1118 additions and 65 deletions
+150 -28
View File
@@ -209,6 +209,31 @@
}
.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>
@@ -227,10 +252,11 @@
<line x1="9" y1="11" x2="15" y2="11"/>
</svg>
</div>
<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">
@@ -254,13 +280,26 @@
</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 class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
</div>
<div class="ax-field">
<label>Срок сдачи</label>
@@ -292,6 +331,15 @@
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]));
}
@@ -359,8 +407,10 @@
}
/* ── Assign modal ── */
let assignSlug = null;
let assignTitle = null;
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;
@@ -371,6 +421,25 @@
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;
@@ -380,6 +449,7 @@
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 = 'Загрузка…';
@@ -409,6 +479,45 @@
};
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');
@@ -416,37 +525,50 @@
errorEl.classList.remove('visible');
successEl.classList.remove('visible');
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
.map(el => Number(el.value));
if (!checked.length) {
errorEl.textContent = 'Выберите хотя бы один класс';
errorEl.classList.add('visible');
return;
}
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 {
const titleSuffix = paragraphs ? `${paragraphs})` : '';
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
class_ids: checked,
mode: 'exam', // mode is required, but for textbook assignment is informational
count: 1,
subject_slug: 'other',
is_homework: 1,
deadline: deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
successEl.textContent = `Назначено в ${r.count || checked.length} класс(е/ах)`;
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);