From 6ee67c0653902c67dc372cbc15a4310c1302ce60 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 16:51:08 +0300 Subject: [PATCH] feat(reader): remember reading position across refresh, add chapter progress bar, and plan PDF support --- TODO.md | 45 +++++++++-- app/static/css/style.css | 35 +++++++++ app/static/index.html | 9 ++- app/static/js/app.js | 158 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 231 insertions(+), 16 deletions(-) diff --git a/TODO.md b/TODO.md index 48028c5..d24c099 100644 --- a/TODO.md +++ b/TODO.md @@ -120,7 +120,42 @@ --- -### 6. πŸ”Œ ΠŸΡƒΠ» соСдинСний HTTP (`httpx.AsyncClient` с Keep-Alive) +### 6. πŸ“– Π—Π°ΠΏΠΎΠΌΠΈΠ½Π°Π½ΠΈΠ΅ ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ чтСния ΠΈ прогрСсс Π³Π»Π°Π²Ρ‹ (Scroll position, Chapter Progress & Auto-restore) +- [x] **Π˜Π½Π΄ΠΈΠΊΠ°Ρ‚ΠΎΡ€ прогрСсса Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΉ Π³Π»Π°Π²Ρ‹**: + - Π’ΠΎΠ½ΠΊΠΈΠΉ Π½Π΅ΠΎΠ½ΠΎΠ²Ρ‹ΠΉ прогрСсс-Π±Π°Ρ€ Π² шапкС Ρ‡ΠΈΡ‚Π°Π»ΠΊΠΈ (`reader-chapter-progress-bar`), ΠΏΠ»Π°Π²Π½ΠΎ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‰ΠΈΠΉ ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚ ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½Π½ΠΎΠ³ΠΎ Π² Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΉ Π³Π»Π°Π²Π΅. + - ΠŸΡ€ΠΎΡ†Π΅Π½Ρ‚Π½Ρ‹ΠΉ Π±Π΅ΠΉΠ΄ΠΆ Π² Ρ†Π΅Π½Ρ‚Ρ€Π΅ Ρ‚ΡƒΠ»Π±Π°Ρ€Π° Ρ‡ΠΈΡ‚Π°Π»ΠΊΠΈ рядом с Π²Ρ‹Π±ΠΎΡ€ΠΎΠΌ Π³Π»Π°Π²Ρ‹ (`reader-scroll-pct-badge`, Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ `42%`). +- [x] **АвтоматичСскоС сохранСниС ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ чтСния**: + - ΠžΡ‚ΡΠ»Π΅ΠΆΠΈΠ²Π°Π½ΠΈΠ΅ скролла Ρ‡ΠΈΡ‚Π°Π»ΠΊΠΈ Π² Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠΌ Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ (`handleReaderScroll`). + - МгновСнноС сохранСниС Π² `localStorage` (`notes_book_{id}_pos`: Π½ΠΎΠΌΠ΅Ρ€ Π³Π»Π°Π²Ρ‹, Ρ‚ΠΎΡ‡Π½Ρ‹Π΅ пиксСли `scrollTop`, доля прогрСсса `fraction`). + - Ѐоновая ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° прогрСсса Π½Π° бэкСнд (`PUT /api/books/{id}/progress`) с дСбаунсом 800 мс. + - Гарантированная фиксация ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ ΠΏΡ€ΠΈ Π·Π°ΠΊΡ€Ρ‹Ρ‚ΠΈΠΈ/ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠΈ страницы Ρ‡Π΅Ρ€Π΅Π· `beforeunload` + `navigator.sendBeacon`. +- [x] **Π‘Π΅ΡΡˆΠΎΠ²Π½ΠΎΠ΅ восстановлСниС чтСния ΠΏΡ€ΠΈ ΠΏΠ΅Ρ€Π΅Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ страницы**: + - БСрвис Π·Π°ΠΏΠΎΠΌΠΈΠ½Π°Π΅Ρ‚ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ экран ΠΈ ID ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠΉ ΠΊΠ½ΠΈΠ³ΠΈ (`notes_active_book_id`). + - ΠŸΡ€ΠΈ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠΈ страницы (`F5` ΠΈΠ»ΠΈ свайп Π½Π° Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½Π΅) ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ автоматичСски ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Π΅Ρ‚ Ρ‡ΠΈΡ‚Π°Π»ΠΊΡƒ Π½Π° Ρ‚ΠΎΠΉ ΠΆΠ΅ ΠΊΠ½ΠΈΠ³Π΅, Ρ‚ΠΎΠΉ ΠΆΠ΅ Π³Π»Π°Π²Π΅ ΠΈ восстанавливаСт Ρ‚ΠΎΡ‡Π½ΡƒΡŽ ΠΏΠΎΠ·ΠΈΡ†ΠΈΡŽ скролла Π²ΠΏΠ»ΠΎΡ‚ΡŒ Π΄ΠΎ пиксСля. + +--- + +### 7. πŸ“‘ ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° чтСния PDF (Cisco CCNA, O'Reilly & Tech Books с ΠΎΠ³Π»Π°Π²Π»Π΅Π½ΠΈΠ΅ΠΌ ΠΈ тСкстовым слоСм) +- [ ] **ЛСгковСсный Pure-Python парсСр (`pypdf`, Zero C-Dependency)**: + - ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ `pypdf` (< 400 ΠšΠ‘, чистый Python, Π±Π΅Π· раздувания Docker-ΠΎΠ±Ρ€Π°Π·Π° ΠΈ Π±Π΅Π· систСмных Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊ `poppler-utils` / `mupdf`). +- [ ] **Π˜Π·Π²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ оглавлСния ΠΈ Π·Π°ΠΊΠ»Π°Π΄ΠΎΠΊ PDF (`reader.outline`)**: + - Π˜Π·Π²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ авторского оглавлСния (ToC / Bookmarks), стандартного для ΠΊΠ½ΠΈΠ³ Cisco Press (CCNA Official Cert Guide), O'Reilly ΠΈ Wiley. + - Π Π°Π·Π±ΠΈΠ²ΠΊΠ° ΠΊΠ½ΠΈΠ³ΠΈ Π½Π° логичСскиС Π³Π»Π°Π²Ρ‹ ΠΈ Ρ€Π°Π·Π΄Π΅Π»Ρ‹ (`Part I`, `Chapter 1`, `Chapter 2`) Ρ‚ΠΎΡ‡Π½ΠΎ ΠΏΠΎ оглавлСнию Π°Π²Ρ‚ΠΎΡ€Π°. +- [ ] **Fallback-Ρ€Π°Π·Π±ΠΈΠ²ΠΊΠ° ΠΏΡ€ΠΈ отсутствии оглавлСния**: + - ΠŸΡ€ΠΈ отсутствии PDF-Π·Π°ΠΊΠ»Π°Π΄ΠΎΠΊ β€” Π°Π²Ρ‚ΠΎΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠΎΠ² Ρ‡Π΅Ρ€Π΅Π· regex (`Chapter \d+`, `Π“Π»Π°Π²Π° \d+`) ΠΈΠ»ΠΈ Π³Ρ€ΡƒΠΏΠΏΠΈΡ€ΠΎΠ²ΠΊΠ° страниц порциями ΠΏΠΎ ~12–15 страниц. +- [ ] **ΠžΡ‡ΠΈΡΡ‚ΠΊΠ° ΡˆΡƒΠΌΠ° ΠΈ ΠΊΠΎΠ»ΠΎΠ½Ρ‚ΠΈΡ‚ΡƒΠ»ΠΎΠ²**: + - Π€ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ ΠΏΠΎΠ²Ρ‚ΠΎΡ€ΡΡŽΡ‰ΠΈΡ…ΡΡ Π²Π΅Ρ€Ρ…Π½ΠΈΡ…/Π½ΠΈΠΆΠ½ΠΈΡ… ΠΊΠΎΠ»ΠΎΠ½Ρ‚ΠΈΡ‚ΡƒΠ»ΠΎΠ² (Β«CCNA 200-301 Official Cert GuideΒ», Β«Page 45 of 900Β»). +- [ ] **Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ CLI/Console Π±Π»ΠΎΠΊΠΎΠ²**: + - РаспознаваниС консольного Π²Ρ‹Π²ΠΎΠ΄Π° ΠΈ ΠΊΠΎΠΌΠ°Π½Π΄ сСтСвого оборудования (`Router# show ip interface brief`, `Switch(config)#`) ΠΈ ΠΎΠ±ΠΎΡ€Π°Ρ‡ΠΈΠ²Π°Π½ΠΈΠ΅ Π² Π±Π»ΠΎΠΊΠΈ ΠΊΠΎΠ΄Π° Markdown для Ρ‡ΠΈΡ‚Π°Π»ΠΊΠΈ с ΠΊΠ½ΠΎΠΏΠΊΠΎΠΉ Β«ΠšΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒΒ». +- [ ] **Π—Π°Ρ‰ΠΈΡ‚Π° ΠΎΡ‚ сканов Π±Π΅Π· OCR (Scanned PDF Guard)**: + - Валидация наличия тСкстового слоя: Ссли суммарный ΠΎΠ±ΡŠΡ‘ΠΌ ΠΈΠ·Π²Π»Π΅Ρ‡Ρ‘Π½Π½ΠΎΠ³ΠΎ тСкста < 100 слов, Π²ΠΎΠ·Π²Ρ€Π°Ρ‚ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΠ²Π½ΠΎΠΉ ошибки с ΠΏΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΠ΅ΠΌ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ тСкстовый PDF ΠΈΠ»ΠΈ OCR. +- [ ] **Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ Π² интСрфСйс Ρ‡ΠΈΡ‚Π°Π»ΠΊΠΈ**: + - Π Π°Π·Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ `.pdf` Π² ``. + - ОбновлСниС ΠΊΠ½ΠΎΠΏΠΊΠΈ Π² Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠ΅: `πŸ“ Π—Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ EPUB / PDF / TXT`. + +--- + +### 8. πŸ”Œ ΠŸΡƒΠ» соСдинСний HTTP (`httpx.AsyncClient` с Keep-Alive) - [ ] Π’ [`app/dictionary.py`](file:///home/ai-projects/services/study.qpid.pro/app/dictionary.py) ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ синглтон-ΠΊΠ»ΠΈΠ΅Π½Ρ‚ `get_http_client()` с ΠΏΡƒΠ»ΠΎΠΌ соСдинСний: ```python limits = httpx.Limits(max_keepalive_connections=10, max_connections=20) @@ -130,7 +165,7 @@ --- -### 7. πŸ—„οΈ Вюнинг SQLite PRAGMA ΠΈ составных индСксов +### 9. πŸ—„οΈ Вюнинг SQLite PRAGMA ΠΈ составных индСксов - [ ] Π’ [`app/database.py`](file:///home/ai-projects/services/study.qpid.pro/app/database.py): - ВынСсти `PRAGMA journal_mode=WAL;` Π² `init_db()` (ΡƒΠ±Ρ€Π°Ρ‚ΡŒ ΠΈΠ·Π±Ρ‹Ρ‚ΠΎΡ‡Π½Ρ‹ΠΉ Π²Ρ‹Π·ΠΎΠ² ΠΏΡ€ΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΌ запросС). - Π’ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹ ΠΏΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ памяти Linux: @@ -143,13 +178,13 @@ --- -### 8. βœ‚οΈ Π”Π΅-Π±Π»ΠΎΠ°Ρ‚ΠΈΠ½Π³ Π²Π½Π΅ΡˆΠ½ΠΈΡ… парсСров (YAGNI) +### 10. βœ‚οΈ Π”Π΅-Π±Π»ΠΎΠ°Ρ‚ΠΈΠ½Π³ Π²Π½Π΅ΡˆΠ½ΠΈΡ… парсСров (YAGNI) - [ ] Π£Π΄Π°Π»ΠΈΡ‚ΡŒ лишний сСтСвой опрос Datamuse (`fetch_datamuse`) β€” ΠΎΠ½ Π΄ΡƒΠ±Π»ΠΈΡ€ΡƒΠ΅Ρ‚ опрСдСлСния ΠΈ ΠΏΠ΅Ρ€Π΅Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ ΠΎΡ‚Π²Π΅Ρ‚ Π½Π΅Ρ€Π΅Π»Π΅Π²Π°Π½Ρ‚Π½Ρ‹ΠΌΠΈ Ρ€ΠΈΡ„ΠΌΠ°ΠΌΠΈ. - [ ] Π£ΠΏΡ€ΠΎΡΡ‚ΠΈΡ‚ΡŒ Ρ…Ρ€ΡƒΠΏΠΊΠΈΠΉ regex-парсинг Ρ€Π°Π·ΠΌΠ΅Ρ‚ΠΊΠΈ Wikitext, ΠΎΠΏΠΈΡ€Π°ΡΡΡŒ Π½Π° ΡΡ‚Π°Π±ΠΈΠ»ΡŒΠ½Ρ‹Π΅ REST JSON эндпоинты. --- -### 9. πŸ“š Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΠΎΡ„Ρ„Π»Π°ΠΉΠ½-ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ (99%+ покрытия со словоформами, SQLite 10–20 ΠœΠ‘) +### 11. πŸ“š Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΠΎΡ„Ρ„Π»Π°ΠΉΠ½-ΡΠ»ΠΎΠ²Π°Ρ€ΡŒ (99%+ покрытия со словоформами, SQLite 10–20 ΠœΠ‘) - [ ] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π² SQLite Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ: ```sql CREATE TABLE IF NOT EXISTS offline_dictionary ( @@ -171,7 +206,7 @@ --- -### 10. 🧩 ΠœΠΎΠ΄ΡƒΠ»ΡŒΠ½ΠΎΡΡ‚ΡŒ Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄Π° Π½Π° Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹Ρ… ES-модулях (Π±Π΅Π· Webpack/Vite) +### 12. 🧩 ΠœΠΎΠ΄ΡƒΠ»ΡŒΠ½ΠΎΡΡ‚ΡŒ Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄Π° Π½Π° Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹Ρ… ES-модулях (Π±Π΅Π· Webpack/Vite) - [ ] ΠŸΠ΅Ρ€Π΅Π²Π΅ΡΡ‚ΠΈ [`app/static/js/app.js`](file:///home/ai-projects/services/study.qpid.pro/app/static/js/app.js) Π½Π° Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹ΠΉ Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π½Ρ‹ΠΉ синтаксис ` + diff --git a/app/static/js/app.js b/app/static/js/app.js index 4923cfe..d40a0a5 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -118,6 +118,9 @@ document.addEventListener('DOMContentLoaded', () => { const readerTrainBtn = document.getElementById('reader-train-btn'); const readerContentBody = document.getElementById('reader-content-body'); const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerChapterProgressBar = document.getElementById('reader-chapter-progress-bar'); + const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill'); + const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge'); const readerWordPopup = document.getElementById('reader-word-popup'); const popupTerm = document.getElementById('popup-term'); @@ -392,7 +395,23 @@ document.addEventListener('DOMContentLoaded', () => { const data = await resp.json(); currentUser = data.user; showApp(); - loadInitialData(); + await loadInitialData(); + + // Restore last active view and reading session if page was refreshed + try { + const lastView = localStorage.getItem('notes_active_view'); + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastView === 'reader-view') { + openReaderView(); + if (lastBookId) { + openBook(parseInt(lastBookId, 10)); + } + } else if (lastView === 'srs-view') { + openSrsView(); + } + } catch (e) { + console.error('State restore error:', e); + } } else { showLogin(); } @@ -1014,6 +1033,7 @@ document.addEventListener('DOMContentLoaded', () => { if (readerView) readerView.classList.add('hidden'); dictionaryView.classList.remove('hidden'); updateMobileActiveTab('dict'); + try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {} loadInitialData(); } @@ -1022,6 +1042,7 @@ document.addEventListener('DOMContentLoaded', () => { if (readerView) readerView.classList.add('hidden'); srsView.classList.remove('hidden'); updateMobileActiveTab('srs'); + try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {} startSrsTrainingSession(false, tagFilter); } @@ -1030,6 +1051,7 @@ document.addEventListener('DOMContentLoaded', () => { if (readerView) readerView.classList.add('hidden'); dictionaryView.classList.remove('hidden'); updateMobileActiveTab('dict'); + try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {} loadInitialData(); } @@ -1038,6 +1060,7 @@ document.addEventListener('DOMContentLoaded', () => { srsView.classList.add('hidden'); if (readerView) readerView.classList.remove('hidden'); updateMobileActiveTab('reader'); + try { localStorage.setItem('notes_active_view', 'reader-view'); } catch (e) {} loadBookshelf(); } @@ -1990,7 +2013,25 @@ document.addEventListener('DOMContentLoaded', () => { const resp = await fetch(`/api/books/${bookId}`); if (!resp.ok) return alert('НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ ΠΊΠ½ΠΈΠ³Ρƒ'); currentBook = await resp.json(); - currentChapterIndex = currentBook.current_chapter || 0; + + // Load saved reading position from localStorage or server + let targetChapter = currentBook.current_chapter || 0; + let targetScrollTop = null; + let targetFraction = currentBook.current_position || 0; + + try { + const raw = localStorage.getItem(`notes_book_${bookId}_pos`); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.chapter === 'number') { + targetChapter = parsed.chapter; + targetScrollTop = parsed.scrollTop; + targetFraction = parsed.fraction; + } + } + } catch (e) {} + + currentChapterIndex = targetChapter; readerBookTitle.textContent = currentBook.title; readerBookAuthor.textContent = currentBook.author || ''; @@ -2009,13 +2050,18 @@ document.addEventListener('DOMContentLoaded', () => { readerInterface.classList.remove('hidden'); applyReaderSettings(); - renderChapter(currentChapterIndex); + try { + localStorage.setItem('notes_active_book_id', bookId); + localStorage.setItem('notes_active_view', 'reader-view'); + } catch (e) {} + + renderChapter(currentChapterIndex, targetScrollTop, targetFraction); } catch (e) { console.error('Error opening book:', e); } } - function renderChapter(index) { + function renderChapter(index, restoreScrollTop = null, restoreFraction = null) { if (!currentBook || !currentBook.chapters) return; currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1)); readerChapterSelect.value = currentChapterIndex; @@ -2151,13 +2197,30 @@ document.addEventListener('DOMContentLoaded', () => { } }); - if (readerScrollArea) readerScrollArea.scrollTop = 0; + function applyScroll() { + if (!readerScrollArea) return; + if (restoreScrollTop !== null && restoreScrollTop > 0) { + readerScrollArea.scrollTop = restoreScrollTop; + } else if (restoreFraction !== null && restoreFraction > 0) { + const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight); + readerScrollArea.scrollTop = Math.round(maxScroll * restoreFraction); + } else { + readerScrollArea.scrollTop = 0; + } + updateReaderProgressBar(); + } + + requestAnimationFrame(() => { + applyScroll(); + setTimeout(applyScroll, 100); + }); // Save progress to server + const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0; fetch(`/api/books/${currentBook.id}/progress`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: 0 }), + body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: posToSave }), }).catch(err => console.log('Progress save error:', err)); } @@ -2820,20 +2883,20 @@ document.addEventListener('DOMContentLoaded', () => { // Chapter Navigation & Reader Toolbar if (readerChapterSelect) { readerChapterSelect.addEventListener('change', () => { - renderChapter(parseInt(readerChapterSelect.value, 10)); + renderChapter(parseInt(readerChapterSelect.value, 10), 0, 0); }); } if (readerPrevChBtn) { readerPrevChBtn.addEventListener('click', () => { - if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1); + if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1, 0, 0); }); } if (readerNextChBtn) { readerNextChBtn.addEventListener('click', () => { if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) { - renderChapter(currentChapterIndex + 1); + renderChapter(currentChapterIndex + 1, 0, 0); } }); } @@ -2842,10 +2905,87 @@ document.addEventListener('DOMContentLoaded', () => { readerBackBtn.addEventListener('click', () => { readerInterface.classList.add('hidden'); bookshelfContainer.classList.remove('hidden'); + try { localStorage.removeItem('notes_active_book_id'); } catch (e) {} loadBookshelf(); }); } + // Scroll Position Tracking & Real-Time Chapter Progress + let readerScrollSaveTimer = null; + + function getReaderScrollMetrics() { + if (!readerScrollArea) return { scrollTop: 0, maxScroll: 0, pct: 0, fraction: 0 }; + const scrollTop = readerScrollArea.scrollTop; + const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight); + const fraction = maxScroll > 0 ? Math.min(1.0, Math.max(0, scrollTop / maxScroll)) : 0; + const pct = Math.min(100, Math.max(0, Math.round(fraction * 100))); + return { scrollTop, maxScroll, pct, fraction }; + } + + function updateReaderProgressBar() { + const { pct } = getReaderScrollMetrics(); + if (readerChapterProgressFill) { + readerChapterProgressFill.style.width = `${pct}%`; + } + if (readerScrollPctBadge) { + readerScrollPctBadge.textContent = `${pct}%`; + } + } + + function handleReaderScroll() { + if (!currentBook || !readerScrollArea) return; + updateReaderProgressBar(); + + const { scrollTop, fraction } = getReaderScrollMetrics(); + + // 1. Instant local persistence + try { + const key = `notes_book_${currentBook.id}_pos`; + localStorage.setItem(key, JSON.stringify({ + chapter: currentChapterIndex, + scrollTop: scrollTop, + fraction: fraction, + savedAt: Date.now() + })); + localStorage.setItem('notes_active_book_id', currentBook.id); + localStorage.setItem('notes_active_view', 'reader-view'); + } catch (e) {} + + // 2. Debounced server sync (every 800ms) + clearTimeout(readerScrollSaveTimer); + readerScrollSaveTimer = setTimeout(() => { + if (!currentBook) return; + fetch(`/api/books/${currentBook.id}/progress`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction }), + }).catch(err => console.log('Progress save error:', err)); + }, 800); + } + + if (readerScrollArea) { + readerScrollArea.addEventListener('scroll', handleReaderScroll, { passive: true }); + } + + window.addEventListener('beforeunload', () => { + if (currentBook && readerScrollArea) { + const { scrollTop, fraction } = getReaderScrollMetrics(); + try { + localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({ + chapter: currentChapterIndex, + scrollTop: scrollTop, + fraction: fraction, + savedAt: Date.now() + })); + localStorage.setItem('notes_active_book_id', currentBook.id); + } catch (e) {} + if (navigator.sendBeacon) { + const blob = new Blob([JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction })], { type: 'application/json' }); + navigator.sendBeacon(`/api/books/${currentBook.id}/progress`, blob); + } + } + }); + function loadSavedReaderSettings() { try { const saved = localStorage.getItem('notes_reader_settings');