'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 = `
Закладок нет
Выдели любой текст в учебнике и нажми «+ Закладка»
`; 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) { 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(); } })();