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
+210 -4
View File
@@ -217,19 +217,225 @@
document.head.appendChild(s);
}
/* ── 9a. Bookmarks (highlights/notes) ─────────────────────────── */
let bookmarks = [];
function loadBookmarks() {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return Promise.resolve();
return fetch('/api/textbooks/' + slug + '/bookmarks', {
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
})
.then(r => r.ok ? r.json() : { bookmarks: [] })
.then(d => { bookmarks = d.bookmarks || []; })
.catch(() => {});
}
function escHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function installBookmarksBtn() {
if (document.getElementById('tb-bm-btn')) return;
const btn = document.createElement('button');
btn.id = 'tb-bm-btn';
btn.title = 'Мои закладки';
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`;
Object.assign(btn.style, {
position: 'fixed', top: '10px', left: '125px', zIndex: '9999',
width: '34px', height: '32px', border: 'none', borderRadius: '20px',
background: 'rgba(0,0,0,.45)', color: '#fff',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', backdropFilter: 'blur(6px)',
transition: 'background .15s, transform .12s',
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
});
btn.querySelector('svg').style.cssText = 'width:14px;height:14px';
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; });
btn.addEventListener('click', toggleBookmarksPanel);
document.body.appendChild(btn);
}
function installBookmarksPanel() {
if (document.getElementById('tb-bm-panel')) return;
const panel = document.createElement('div');
panel.id = 'tb-bm-panel';
Object.assign(panel.style, {
position: 'fixed', top: '50px', left: '12px', zIndex: '9998',
width: '320px', maxHeight: '60vh', overflowY: 'auto',
background: '#fff', color: '#1c1917',
border: '1px solid rgba(0,0,0,.12)', borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,.25)',
padding: '12px', display: 'none',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '13px',
});
document.body.appendChild(panel);
}
function toggleBookmarksPanel() {
const panel = document.getElementById('tb-bm-panel');
if (panel.style.display === 'block') { panel.style.display = 'none'; return; }
renderBookmarksPanel();
panel.style.display = 'block';
}
function renderBookmarksPanel() {
const panel = document.getElementById('tb-bm-panel');
if (!panel) return;
if (!bookmarks.length) {
panel.innerHTML = `<div style="padding:14px;text-align:center;color:#78716c">
Закладок нет<br><small>Выдели любой текст в учебнике и нажми «+ Закладка»</small>
</div>`;
return;
}
panel.innerHTML = `
<div style="font-weight:800;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#78716c;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid rgba(0,0,0,.08)">
Мои закладки (${bookmarks.length})
</div>
${bookmarks.map(b => {
const colorMap = { yellow:'#fef08a', green:'#bbf7d0', blue:'#bfdbfe', pink:'#fbcfe8' };
const borderMap= { yellow:'#ca8a04', green:'#16a34a', blue:'#2563eb', pink:'#db2777' };
const bg = colorMap[b.color] || colorMap.yellow;
const bd = borderMap[b.color] || borderMap.yellow;
const paraLink = b.para ? `<a href="#${b.para}" style="text-decoration:none;color:${bd};font-weight:700;margin-right:6px">§${b.para.replace('p','')}</a>` : '';
return `<div style="background:${bg};border-left:3px solid ${bd};padding:8px 10px;border-radius:6px;margin-bottom:7px;position:relative">
${paraLink}<span style="font-size:12.5px">«${escHtml(b.text)}»</span>
${b.note ? `<div style="margin-top:5px;color:#44403c;font-style:italic;font-size:12px">${escHtml(b.note)}</div>` : ''}
<button onclick="window.__tbDeleteBookmark(${b.id})" style="position:absolute;top:5px;right:5px;border:none;background:none;cursor:pointer;color:#78716c;font-size:13px;line-height:1">×</button>
</div>`;
}).join('')}`;
}
window.__tbDeleteBookmark = function (id) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
fetch('/api/textbooks/bookmarks/' + id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
}).then(r => {
if (r.ok) {
bookmarks = bookmarks.filter(b => b.id !== id);
renderBookmarksPanel();
}
}).catch(() => {});
};
/* Selection → "+ Закладка" floating button */
function installSelectionHandler() {
let btn = null;
document.addEventListener('mouseup', () => {
setTimeout(() => {
const sel = window.getSelection();
const text = (sel ? sel.toString() : '').trim();
if (!text || text.length < 8 || text.length > 400) { hideBtn(); return; }
if (sel.rangeCount === 0) { hideBtn(); return; }
const rect = sel.getRangeAt(0).getBoundingClientRect();
if (!rect || rect.width === 0) { hideBtn(); return; }
showBtn(rect, text);
}, 10);
});
document.addEventListener('mousedown', e => {
if (e.target.closest('#tb-sel-btn')) return;
hideBtn();
});
function showBtn(rect, text) {
if (!btn) {
btn = document.createElement('button');
btn.id = 'tb-sel-btn';
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg><span>Закладка</span>`;
Object.assign(btn.style, {
position: 'fixed', zIndex: '10000',
display: 'inline-flex', alignItems: 'center', gap: '5px',
padding: '6px 11px', borderRadius: '6px',
border: 'none', background: '#1c1917', color: '#fff',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12px', fontWeight: '700',
cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.3)',
});
document.body.appendChild(btn);
}
btn.style.top = (rect.top + window.scrollY - 36) + 'px';
btn.style.left = (rect.left + window.scrollX + rect.width / 2 - 50) + 'px';
btn.style.display = 'inline-flex';
btn.onclick = () => createBookmarkFromSelection(text);
}
function hideBtn() { if (btn) btn.style.display = 'none'; }
}
function createBookmarkFromSelection(text) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) {
alert('Сохранение закладок доступно после входа в систему');
return;
}
const note = prompt('Заметка к закладке (опционально):', '') || '';
// Find current paragraph from selection
const sel = window.getSelection();
let para = null;
if (sel.rangeCount > 0) {
let node = sel.getRangeAt(0).startContainer;
while (node && node !== document.body) {
if (node.dataset?.para) { para = node.dataset.para; break; }
if (node.id && /^p\d+$/.test(node.id)) { para = node.id; break; }
if (node.id && /^ptab-p\d+$/.test(node.id)) { para = node.id.replace('ptab-', ''); break; }
node = node.parentNode;
}
}
if (!para) para = localState.last; // fallback
fetch('/api/textbooks/' + slug + '/bookmarks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LS.getToken(),
},
body: JSON.stringify({ text, note, para, color: 'yellow' }),
})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return;
bookmarks.unshift({ id: d.id, text, note, para, color: 'yellow', created_at: new Date().toISOString() });
const btnEl = document.getElementById('tb-sel-btn');
if (btnEl) btnEl.style.display = 'none';
window.getSelection()?.removeAllRanges();
});
}
/* ── 9. Boot ──────────────────────────────────────────────────── */
function openParaByKey(key) {
if (!key) return;
if (typeof setParaTab === 'function') {
try { setParaTab(key); return; } catch {}
}
const pill = document.querySelector(`.para-pill[data-para="${key}"]`);
if (pill) pill.click();
}
function handleHashNav() {
const m = (location.hash || '').match(/^#(p\d+)$/);
if (m) {
openParaByKey(m[1]);
setLastPara(m[1]);
return true;
}
return false;
}
function boot() {
injectStyles();
installBackButton();
installBookmarksBtn();
installBookmarksPanel();
installSelectionHandler();
installReadCheckboxes();
wirePillTracking();
refreshAllUI();
loadServerProgress();
// Auto-open last paragraph if pill exists
if (localState.last) {
const pill = document.querySelector(`.para-pill[data-para="${localState.last}"]`);
if (pill) setTimeout(() => pill.click(), 50);
loadBookmarks();
// Priority: URL hash > last visited paragraph
if (!handleHashNav() && localState.last) {
setTimeout(() => openParaByKey(localState.last), 50);
}
window.addEventListener('hashchange', handleHashNav);
}
if (document.readyState === 'loading') {