Files
Learn_System/frontend/js/textbook-tracker.js
T
Maxim Dolgolyov dacc0eb4ac fix(tracker): mark_read больше не дропается из-за syncPending
Раньше: клик по .para-pill вызывал setLastPara() → POST с last_para
→ syncPending=true. Тут же вызывался markRead() → второй POST с
mark_read → guard 'if (syncPending) return' молча отбрасывал его.
Результат: каталог показывал 'Продолжить' (last_para пришёл),
но '0 из N прочитано' (paragraphs_read остался пуст).

Два уровня фикса:
1) wirePillTracking объединяет last_para + mark_read в ОДИН POST
   через коалесцирующий syncToServer(firstTime ? {mark_read:key} : {})
2) syncToServer теперь не дропает патчи: если предыдущий POST в
   полёте, новые поля сохраняются в pendingExtra и отправляются
   после .finally() — гарантия 'ни один mark_read не теряется'.

Затрагивает chemistry-9, physics-9, physics8_thermal/electro/optics —
у них теперь '0/N прочитано' начнёт расти при кликах по пилюлям.
2026-05-27 17:08:49 +03:00

472 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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$/, '');
// Normalise physics8_* → physics-8-* (e.g. physics8_thermal → physics-8-thermal)
const norm = fname.replace(/^physics8_/, 'physics-8-');
return norm.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, с очередью) ─────────────────────
Если POST уже в полёте — следующий патч копится в pendingExtra
и отправляется после завершения. Так ни один mark_read не
теряется при быстрых кликах. */
let syncPending = false;
let pendingExtra = null;
function syncToServer(extra) {
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
if (syncPending) {
pendingExtra = Object.assign(pendingExtra || {}, extra || {});
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;
if (pendingExtra) {
const next = pendingExtra; pendingExtra = null;
syncToServer(next);
}
}).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 → last_para + (первый раз) mark_read ────────
Объединяем оба обновления в один POST, чтобы syncPending-guard
в syncToServer не дропнул второй вызов в том же тике. */
function wirePillTracking() {
document.body.addEventListener('click', e => {
const pill = e.target.closest('.para-pill[data-para]');
if (!pill) return;
const key = pill.dataset.para;
localState.last = key;
const firstTime = !localState.read.includes(key);
if (firstTime) {
localState.read.push(key);
refreshPillUI(key);
refreshCheckUI(key);
}
persist();
syncToServer(firstTime ? { mark_read: 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);
}
/* ── 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();
loadBookmarks();
// Priority: URL hash > last visited paragraph
if (!handleHashNav() && localState.last) {
setTimeout(() => openParaByKey(localState.last), 50);
}
window.addEventListener('hashchange', handleHashNav);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();