Files
Learn_System/frontend/js/textbook-tracker.js
T
Maxim Dolgolyov e8018d85c1 feat: textbooks — модуль учебников + чтение как ДЗ (3 фазы)
Фаза 1 — структура и каталог:
  - frontend/textbooks/chemistry_9.html (Шиманович, 60 §) + physics_9.html (Исаченкова, 38 §)
  - frontend/textbooks.html — каталог в стиле LearnSpace (карточки с обложками)
  - Маршруты: /textbooks (каталог), /textbook/<slug> (полноэкранный учебник)
  - Сайдбар: пункт «Учебники» (book-open-text)
  - Feature flag feature_textbooks_enabled, hideDisabledFeatures map

Фаза 2 — прогресс в localStorage + UI чтения:
  - frontend/js/textbook-tracker.js — инжектится в каждый учебник:
    - «← Учебники» overlay-кнопка (top-left, semi-transparent)
    - «Прочитано» чекбокс рядом с каждым §-заголовком
    - Зелёный dot на pill уже прочитанных параграфов
    - Авто-открытие последнего параграфа при возврате
  - Каталог показывает прогресс-бар «X из Y прочитано» + кнопку «Продолжить»

Фаза 3 — серверный прогресс + назначение чтения как ДЗ:
  - Таблица textbooks (slug, subject, grade, title, author, color, ...)
  - Таблица textbook_progress (user_id, textbook_id, JSON read[], last_para)
  - Колонки assignments.textbook_id + textbook_paragraphs
  - API: GET /api/textbooks (с прогрессом), GET /:slug, POST /:slug/progress,
    GET /:slug/class-progress (учитель)
  - tracker.js синхронизирует прогресс через POST /progress (если залогинен)
  - На каталоге у учителей кнопка «Назначить чтение» — модалка с выбором
    классов + параграфы («1-5» или «1,3,5») + deadline
  - bulkCreateAssignment расширен: принимает textbook_slug, резолвит в id

Миграция 004 идемпотентная; сиды двух учебников включены.
2026-05-16 14:05:19 +03:00

241 lines
10 KiB
JavaScript

'use strict';
/* ──────────────────────────────────────────────────────────────────
textbook-tracker.js — injected into each textbook page.
- "Back to LearnSpace" button overlay (top-left)
- localStorage progress tracking (always works, even logged out)
- Server-side sync when authenticated (via LS.api)
- Per-paragraph "Прочитано" checkbox UI
────────────────────────────────────────────────────────────────── */
(function () {
const slug = (function () {
const m = location.pathname.match(/\/textbook\/([\w-]+)/);
if (m) return m[1];
// Fallback for direct file access during dev
const fname = location.pathname.split('/').pop().replace(/\.html$/, '');
return fname.replace(/_/g, '-');
})();
const lsKey = 'textbook_progress_' + slug;
const localState = (function () {
try { return JSON.parse(localStorage.getItem(lsKey) || '{"read":[],"last":null}'); }
catch { return { read: [], last: null }; }
})();
if (!Array.isArray(localState.read)) localState.read = [];
/* ── 1. Server sync (best-effort) ──────────────────────────────── */
let syncPending = false;
function syncToServer(extra) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
if (syncPending) return;
syncPending = true;
fetch('/api/textbooks/' + slug + '/progress', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + LS.getToken(),
},
body: JSON.stringify({ last_para: localState.last, ...extra }),
}).finally(() => { syncPending = false; }).catch(() => {});
}
/* ── 2. Initial load: merge server data into local state ──────── */
function loadServerProgress() {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
fetch('/api/textbooks/' + slug, {
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
})
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d || !d.progress) return;
const merged = Array.from(new Set([...(localState.read || []), ...(d.progress.read || [])]));
localState.read = merged;
if (!localState.last) localState.last = d.progress.last_para;
localStorage.setItem(lsKey, JSON.stringify(localState));
refreshAllUI();
})
.catch(() => {});
}
/* ── 3. Save helpers ──────────────────────────────────────────── */
function persist() { try { localStorage.setItem(lsKey, JSON.stringify(localState)); } catch {} }
function setLastPara(key) {
if (!key) return;
localState.last = key;
persist();
syncToServer({});
}
function markRead(key) {
if (!key || localState.read.includes(key)) return;
localState.read.push(key);
persist();
refreshPillUI(key);
refreshCheckUI(key);
syncToServer({ mark_read: key });
}
function unmarkRead(key) {
if (!key) return;
const i = localState.read.indexOf(key);
if (i < 0) return;
localState.read.splice(i, 1);
persist();
refreshPillUI(key);
refreshCheckUI(key);
syncToServer({ mark_unread: key });
}
function toggleRead(key) {
if (localState.read.includes(key)) unmarkRead(key);
else markRead(key);
}
/* ── 4. UI: back button overlay ───────────────────────────────── */
function installBackButton() {
if (document.getElementById('tb-back-btn')) return;
const btn = document.createElement('a');
btn.id = 'tb-back-btn';
btn.href = '/textbooks';
btn.title = 'К каталогу учебников';
btn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
<span>Учебники</span>`;
Object.assign(btn.style, {
position: 'fixed', top: '10px', left: '12px', zIndex: '9999',
display: 'inline-flex', alignItems: 'center', gap: '6px',
padding: '6px 11px 6px 9px', borderRadius: '20px',
background: 'rgba(0,0,0,.45)', color: '#fff',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12.5px', fontWeight: '700',
textDecoration: 'none', 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;flex-shrink:0';
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; btn.style.transform = 'translateY(-1px)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; btn.style.transform = 'none'; });
document.body.appendChild(btn);
}
/* ── 5. UI: mark-read checkboxes near every paragraph heading ─ */
function installReadCheckboxes() {
// Each para has a wrapper with id='p1','p2' etc. (data-para usage)
document.querySelectorAll('[data-para]').forEach(el => {
// Skip pill buttons (they only navigate); only target paragraph content blocks
if (el.classList.contains('para-pill')) return;
injectCheckIntoSection(el);
});
// Also look for sections by id matching pN pattern in case data-para isn't on the section
document.querySelectorAll('section[id^="p"], div[id^="p"]').forEach(el => {
if (/^p\d+$/.test(el.id) && !el.querySelector(':scope > .tb-readchk')) {
injectCheckIntoSection(el);
}
});
}
function injectCheckIntoSection(sectionEl) {
const key = sectionEl.dataset.para || sectionEl.id;
if (!key || !/^p\d+/.test(key)) return;
if (sectionEl.querySelector(':scope > .tb-readchk')) return;
// Find the first heading inside the section to insert next to it
const heading = sectionEl.querySelector('h1, h2, h3');
if (!heading) return;
const wrap = document.createElement('button');
wrap.className = 'tb-readchk';
wrap.dataset.para = key;
wrap.type = 'button';
wrap.title = 'Отметить как прочитанное';
wrap.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <span>Прочитано</span>`;
Object.assign(wrap.style, {
marginLeft: '12px',
display: 'inline-flex', alignItems: 'center', gap: '5px',
padding: '4px 10px', borderRadius: '99px',
border: '1.5px solid currentColor', background: 'transparent',
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '11.5px', fontWeight: '700',
cursor: 'pointer', verticalAlign: 'middle', opacity: '.55',
transition: 'opacity .15s, background .15s, color .15s',
});
wrap.querySelector('svg').style.cssText = 'width:12px;height:12px;flex-shrink:0';
wrap.addEventListener('mouseenter', () => { wrap.style.opacity = '1'; });
wrap.addEventListener('mouseleave', () => {
if (!localState.read.includes(key)) wrap.style.opacity = '.55';
});
wrap.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
toggleRead(key);
});
heading.appendChild(wrap);
refreshCheckUI(key);
}
/* ── 6. UI refreshers ─────────────────────────────────────────── */
function refreshPillUI(key) {
document.querySelectorAll(`.para-pill[data-para="${key}"]`).forEach(p => {
p.classList.toggle('tb-read', localState.read.includes(key));
});
}
function refreshCheckUI(key) {
document.querySelectorAll(`.tb-readchk[data-para="${key}"]`).forEach(b => {
const isRead = localState.read.includes(key);
b.style.background = isRead ? 'rgba(16,185,129,.15)' : 'transparent';
b.style.color = isRead ? '#059669' : '';
b.style.opacity = isRead ? '1' : '.55';
b.querySelector('span').textContent = isRead ? 'Прочитано' : 'Прочитано';
});
}
function refreshAllUI() {
localState.read.forEach(k => { refreshPillUI(k); refreshCheckUI(k); });
}
/* ── 7. Pill click → mark as last visited ─────────────────────── */
function wirePillTracking() {
document.body.addEventListener('click', e => {
const pill = e.target.closest('.para-pill[data-para]');
if (!pill) return;
const key = pill.dataset.para;
setLastPara(key);
});
}
/* ── 8. Inject styling for read-pills (subtle green dot) ─────── */
function injectStyles() {
const s = document.createElement('style');
s.textContent = `
.para-pill.tb-read { position: relative; }
.para-pill.tb-read::after {
content: ''; position: absolute; top: 3px; right: 3px;
width: 6px; height: 6px; border-radius: 50%;
background: #10b981; box-shadow: 0 0 0 1.5px rgba(255,255,255,.9);
}
@media (max-width: 600px) {
#tb-back-btn span { display: none; }
#tb-back-btn { padding: 7px 8px; }
}
`;
document.head.appendChild(s);
}
/* ── 9. Boot ──────────────────────────────────────────────────── */
function boot() {
injectStyles();
installBackButton();
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);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();