'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 = ` Учебники`; 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 → 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); } /* ── 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 = `