e8018d85c1
Фаза 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 идемпотентная; сиды двух учебников включены.
241 lines
10 KiB
JavaScript
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();
|
|
}
|
|
})();
|