Files
Learn_System/frontend/js/textbook-tracker.js
T
Maxim Dolgolyov 25c0bb2a79 fix(tracker): mark_read шлётся на КАЖДЫЙ клик пилюли (идемпотентно)
Старый syncPending-баг успел залить локальный localState.read данными,
которых нет на сервере. После фиксов firstTime=false для всех ключей в
localState.read, и mark_read иначе никогда не уходил → каталог показывал
0 даже после реальных кликов.

Решение: убрать оптимизацию firstTime. Слать mark_read КАЖДЫЙ раз —
серверный код  if(!arr.includes(mark_read)) arr.push(...)  не добавит
дубликат. Лишний POST стоит копейки, зато система самовосстанавливается
без зависимости от загрузочного backfill.
2026-05-27 17:17:00 +03:00

483 lines
21 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 + push back ──
Если в локальном кэше есть ключи, которых нет на сервере
(последствие старого бага syncPending) — досылаем их через
отдельные mark_read POST'ы. Это лечит «вечный 0/N» у пользователей,
которые до фикса уже накликали кучу пилюль. */
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 serverRead = new Set(d.progress.read || []);
const localRead = localState.read || [];
const missing = localRead.filter(k => !serverRead.has(k));
// объединяем для UI
const merged = Array.from(new Set([...localRead, ...d.progress.read || []]));
localState.read = merged;
if (!localState.last) localState.last = d.progress.last_para;
localStorage.setItem(lsKey, JSON.stringify(localState));
refreshAllUI();
// догоняем сервер последовательно: syncToServer уже коалесцирует
missing.forEach(k => syncToServer({ mark_read: k }));
})
.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
Идемпотентно: на сервере `if (!arr.includes(mark_read)) arr.push(...)`
не добавит дубликат. Эта «избыточность» лечит самовосстановлением
ситуации, когда localState.read был засорён старым syncPending-багом
(есть ключ локально, нет на сервере, mark_read иначе никогда не уйдёт). */
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;
if (!localState.read.includes(key)) {
localState.read.push(key);
}
refreshPillUI(key);
refreshCheckUI(key);
persist();
syncToServer({ 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();
}
})();