'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 => { if (r.status === 403) { window.location.replace('/403'); return null; } return 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 = ` Учебники`; 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 = ` Прочитано`; 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 recordParaVisit(key) { if (!key) return; localState.last = key; if (!localState.read.includes(key)) { localState.read.push(key); } refreshPillUI(key); refreshCheckUI(key); persist(); syncToServer({ mark_read: key }); } function wirePillTracking() { // Hook 1: всплытие click → body. Работает в обычном HTML. document.body.addEventListener('click', e => { const pill = e.target && e.target.closest && e.target.closest('.para-pill[data-para]'); if (!pill) return; recordParaVisit(pill.dataset.para); }); // Hook 2: capture-фаза — ловит до того, как кто-то остановит propagation. document.addEventListener('click', e => { const pill = e.target && e.target.closest && e.target.closest('.para-pill[data-para]'); if (!pill) return; if (pill.__tbVisited) return; pill.__tbVisited = true; setTimeout(() => { pill.__tbVisited = false; }, 100); recordParaVisit(pill.dataset.para); }, true); // Hook 3: monkey-patch setParaTab. chemistry-9 / physics-9 зовут её inline // через onclick="setParaTab('pN')" — перехват на уровне JS-функции // работает даже если event-стек сломан расширением или overlay. function patchSetParaTab() { if (typeof window.setParaTab !== 'function' || window.setParaTab.__tbPatched) return; const orig = window.setParaTab; const wrapped = function (para) { try { if (para && /^p\d+$/i.test(String(para))) recordParaVisit(String(para)); } catch (e) {} return orig.apply(this, arguments); }; wrapped.__tbPatched = true; window.setParaTab = wrapped; } patchSetParaTab(); let tries = 0; const ivl = setInterval(() => { patchSetParaTab(); if (typeof window.setParaTab === 'function' && window.setParaTab.__tbPatched) clearInterval(ivl); if (++tries > 20) clearInterval(ivl); }, 100); // Hook 4: справочник в chemistry-9 / physics-9 использует .tab[data-tab=refN] // (отдельная панель). Маппим ref → p и фиксируем как просмотр параграфа. document.addEventListener('click', e => { const tab = e.target && e.target.closest && e.target.closest('.tab[data-tab]'); if (!tab) return; const m = String(tab.dataset.tab || '').match(/^ref(\d+)$/); if (!m) return; recordParaVisit('p' + m[1]); }, true); // Hook 5: polling — наблюдаем за классом .active на пилюлях. // Срабатывает на любую программную смену активного параграфа. let lastActivePara = null; setInterval(() => { const active = document.querySelector('.para-pill.active[data-para]'); if (!active) return; const para = active.dataset.para; if (para && para !== lastActivePara) { lastActivePara = para; recordParaVisit(para); } }, 500); } /* ── 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } function installBookmarksBtn() { if (document.getElementById('tb-bm-btn')) return; const btn = document.createElement('button'); btn.id = 'tb-bm-btn'; btn.title = 'Мои закладки'; btn.innerHTML = ``; 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 = `
Закладок нет
Выдели любой текст в учебнике и нажми «+ Закладка»
`; return; } panel.innerHTML = `
Мои закладки (${bookmarks.length})
${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 ? `§${b.para.replace('p','')}` : ''; return `
${paraLink}«${escHtml(b.text)}» ${b.note ? `
${escHtml(b.note)}
` : ''}
`; }).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 = `Закладка`; 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) { const key = m[1]; openParaByKey(key); localState.last = key; if (!localState.read.includes(key)) { localState.read.push(key); refreshPillUI(key); refreshCheckUI(key); } persist(); // Hash-вход (например, «Продолжить» из каталога) считается просмотром // параграфа — шлём last_para + mark_read одним POST. syncToServer({ mark_read: key }); 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(); } })();