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:
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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') {
|
||||
|
||||
Reference in New Issue
Block a user