From 21d94acf4ece6eed29f4c12609d8ab9b65e3869c Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 6 Oct 2026 14:27:30 +0300 Subject: [PATCH] feat: decompose frontend into native ES modules (zero bundlers, zero npm) --- TODO.md | 19 +- app/static/index.html | 2 +- app/static/js/app.js | 4332 +++------------------------ app/static/js/modules/api.js | 184 ++ app/static/js/modules/dictionary.js | 732 +++++ app/static/js/modules/reader.js | 2045 +++++++++++++ app/static/js/modules/srs.js | 735 +++++ app/static/js/modules/ui.js | 162 + app/static/sw.js | 7 +- 9 files changed, 4316 insertions(+), 3902 deletions(-) create mode 100644 app/static/js/modules/api.js create mode 100644 app/static/js/modules/dictionary.js create mode 100644 app/static/js/modules/reader.js create mode 100644 app/static/js/modules/srs.js create mode 100644 app/static/js/modules/ui.js diff --git a/TODO.md b/TODO.md index 2b6ddaa..50029de 100644 --- a/TODO.md +++ b/TODO.md @@ -274,14 +274,17 @@ --- -### 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 f63e62a..2b72fdb 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,401 +1,152 @@ -// Notes Application Frontend Logic - Complete SRS, Heatmap, Quiz, and Analytics -// Persistent session: auto-inject Authorization header if token exists in localStorage -const originalFetch = window.fetch; -window.fetch = function(url, options = {}) { - const token = localStorage.getItem('notes_token'); - if (token && typeof url === 'string' && url.startsWith('/api/')) { - options.headers = options.headers || {}; - if (options.headers instanceof Headers) { - if (!options.headers.has('Authorization')) { - options.headers.set('Authorization', `Bearer ${token}`); - } - } else if (Array.isArray(options.headers)) { - if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) { - options.headers.push(['Authorization', `Bearer ${token}`]); - } - } else { - if (!options.headers['Authorization']) { - options.headers['Authorization'] = `Bearer ${token}`; - } - } - } - return originalFetch.call(this, url, options); -}; +// ======================================================================= +// Notes Application - Main Orchestrator & Router (Native ES Module) +// Zero Bundlers, Zero npm, Native browser ES6 modules +// ======================================================================= -document.addEventListener('DOMContentLoaded', () => { - // Register PWA Service Worker - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js', { scope: '/' }) - .then(reg => console.log('Notes PWA ServiceWorker active:', reg.scope)) - .catch(err => console.log('Notes ServiceWorker failed:', err)); +import { api } from './modules/api.js'; +import { initTheme, openModal, closeModal, showToast } from './modules/ui.js'; +import { initDictionary, loadInitialData } from './modules/dictionary.js'; +import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js'; +import { initReader, loadBookshelf, openBook } from './modules/reader.js'; + +let currentUser = null; +let isRouting = false; + +// ----------------------------------------------------------------------- +// Mobile Navigation Active Tab +// ----------------------------------------------------------------------- +export function updateMobileActiveTab(tabName) { + document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => { + btn.classList.remove('active'); + }); + const target = document.getElementById(`tab-nav-${tabName}`); + if (target) target.classList.add('active'); +} + +// ----------------------------------------------------------------------- +// Immediate Pre-flight Routing (Zero-flicker view restoration on F5) +// ----------------------------------------------------------------------- +export function applyInitialViewPreflight() { + let hash = window.location.hash || ''; + if (!hash || hash === '#') { + try { + const lastView = localStorage.getItem('notes_active_view'); + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastView === 'reader-view') { + hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader'; + } else if (lastView === 'srs-view') { + hash = '#srs'; + } + } catch (e) {} } - - // App State - let currentUser = null; - let currentLookupData = null; - let allCards = []; - let selectedTag = ''; - let selectedCollection = ''; - let searchQuery = ''; - let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы']; - - // Batch Selection State - let isBatchMode = false; - let selectedIds = new Set(); - let exportScope = 'all'; - - // SRS State - let srsQueue = []; - let srsCurrentIndex = 0; - let srsCurrentMode = 'flashcards'; // 'flashcards' | 'quiz' | 'typing' | 'cloze' | 'dictation' - let srsDirection = 'en-ru'; - let srsSmartMix = true; - let srsSessionRatings = []; - let srsIsFlipped = false; - let quizCurrentOptions = []; - let quizAnswered = false; - - // Elements - Auth - const loginOverlay = document.getElementById('login-overlay'); - const loginForm = document.getElementById('login-form'); - const loginUsername = document.getElementById('login-username'); - const loginPassword = document.getElementById('login-password'); - const loginError = document.getElementById('login-error'); - const logoutBtn = document.getElementById('logout-btn'); - const userDisplayName = document.getElementById('user-display-name'); - - // Elements - Views & Navigation - const appShell = document.getElementById('app-shell'); const dictionaryView = document.getElementById('dictionary-view'); const srsView = document.getElementById('srs-view'); const readerView = document.getElementById('reader-view'); - const navReaderBtn = document.getElementById('nav-reader-btn'); - const navBrandHome = document.getElementById('nav-brand-home'); - const themeToggle = document.getElementById('theme-toggle'); - const statTotal = document.getElementById('stat-total'); - const statToday = document.getElementById('stat-today'); - const srsTrainerBtn = document.getElementById('srs-trainer-btn'); - const srsBadge = document.getElementById('srs-badge'); - const statsOpenBtn = document.getElementById('stats-open-btn'); - const exportOpenBtn = document.getElementById('export-open-btn'); - const changePwdOpenBtn = document.getElementById('change-pwd-open-btn'); - - // Elements - Reader & Bookshelf const bookshelfContainer = document.getElementById('bookshelf-container'); - const bookshelfEmpty = document.getElementById('bookshelf-empty'); - const bookshelfGrid = document.getElementById('bookshelf-grid'); - const addTextBtn = document.getElementById('add-text-btn'); - const bookFileInput = document.getElementById('book-file-input'); - const addBookModal = document.getElementById('add-book-modal'); - const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn'); - const cancelAddBookBtn = document.getElementById('cancel-add-book-btn'); - const addBookForm = document.getElementById('add-book-form'); - const bookTitleInput = document.getElementById('book-title-input'); - const bookAuthorInput = document.getElementById('book-author-input'); - const bookContentInput = document.getElementById('book-content-input'); - const readerInterface = document.getElementById('reader-interface'); - const readerBackBtn = document.getElementById('reader-back-btn'); const readerBookTitle = document.getElementById('reader-book-title'); - const readerBookAuthor = document.getElementById('reader-book-author'); - const readerChapterSelect = document.getElementById('reader-chapter-select'); - const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); - const readerNextChBtn = document.getElementById('reader-next-ch-btn'); - const readerSettingsBtn = document.getElementById('reader-settings-btn'); - const readerSettingsPanel = document.getElementById('reader-settings-panel'); - const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn'); - const settingFontDecBtn = document.getElementById('setting-font-dec-btn'); - const settingFontIncBtn = document.getElementById('setting-font-inc-btn'); - const settingFontSizeVal = document.getElementById('setting-font-size-val'); - const readerFontFamilyGroup = document.getElementById('reader-font-family-group'); - const readerLineHeightGroup = document.getElementById('reader-line-height-group'); - const readerMarginsGroup = document.getElementById('reader-margins-group'); - const readerThemeGroup = document.getElementById('reader-theme-group'); - const readerAlignGroup = document.getElementById('reader-align-group'); - const readerModeGroup = document.getElementById('reader-mode-group'); - const readerAutoloadGroup = document.getElementById('reader-autoload-group'); - const settingAutoloadRow = document.getElementById('setting-autoload-row'); - const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn'); - 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'); + if (hash.startsWith('#reader') || hash === '#bookshelf') { + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (srsView) srsView.classList.add('hidden'); + if (readerView) readerView.classList.remove('hidden'); + updateMobileActiveTab('reader'); + if (hash.includes('/book/')) { + if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); + if (readerInterface) readerInterface.classList.remove('hidden'); + if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; + } + } else if (hash.startsWith('#srs')) { + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (readerView) readerView.classList.add('hidden'); + if (srsView) srsView.classList.remove('hidden'); + updateMobileActiveTab('srs'); + } +} - const readerWordPopup = document.getElementById('reader-word-popup'); - const popupTerm = document.getElementById('popup-term'); - const popupPhonetic = document.getElementById('popup-phonetic'); - const popupInLibrary = document.getElementById('popup-in-library'); - const popupAudioBtn = document.getElementById('popup-audio-btn'); - const popupCloseBtn = document.getElementById('popup-close-btn'); - const popupLoading = document.getElementById('popup-loading'); - const popupContent = document.getElementById('popup-content'); - const popupTranslation = document.getElementById('popup-translation'); - const popupContext = document.getElementById('popup-context'); - const popupSaveBtn = document.getElementById('popup-save-btn'); - const popupSavedBadge = document.getElementById('popup-saved-badge'); - const popupAddContextBtn = document.getElementById('popup-add-context-btn'); - - // Reader State & Appearance Settings - const DEFAULT_READER_SETTINGS = { - fontSize: 18, - fontFamily: 'serif', - lineHeight: 1.65, - marginWidth: 'normal', - theme: 'dark', - textAlign: 'left', - flowMode: 'chapters', // 'chapters' | 'continuous' - autoLoadNext: true // boolean: seamlessly load next chapter on scroll - }; - let readerSettings = { ...DEFAULT_READER_SETTINGS }; - let currentBook = null; - let currentChapterIndex = 0; - const loadedChaptersSet = new Set(); - let isAppendingChapter = false; - let isHighlightEnabled = false; - let popupCurrentData = null; - - // Elements - Search & Capture - const termInput = document.getElementById('term-input'); - const clearSearchBtn = document.getElementById('clear-search-btn'); - const translateBtn = document.getElementById('translate-btn'); - const duplicateWarningBox = document.getElementById('duplicate-warning-box'); - const dupTermLabel = document.getElementById('dup-term-label'); - const dupTransLabel = document.getElementById('dup-trans-label'); - const dupOpenBtn = document.getElementById('dup-open-btn'); - - const lookupCard = document.getElementById('lookup-card'); - const resTerm = document.getElementById('res-term'); - const resPhonetic = document.getElementById('res-phonetic'); - const resInLibrary = document.getElementById('res-in-library'); - const audioPlayBtn = document.getElementById('audio-play-btn'); - const resPos = document.getElementById('res-pos'); - const resDefinitionWrap = document.getElementById('res-definition-wrap'); - const resDefinition = document.getElementById('res-definition'); - const editTranslation = document.getElementById('edit-translation'); - const editContext = document.getElementById('edit-context'); - const editCollection = document.getElementById('edit-collection'); - const collectionsDatalist = document.getElementById('collections-datalist'); - const editTags = document.getElementById('edit-tags'); - const editNotes = document.getElementById('edit-notes'); - const saveCardBtn = document.getElementById('save-card-btn'); - - // Elements - Vocabulary List - const filterInput = document.getElementById('filter-input'); - const cardsCountLabel = document.getElementById('cards-count-label'); - const collectionsFilterBar = document.getElementById('collections-filter-bar'); - const newCollectionBtn = document.getElementById('new-collection-btn'); - const tagsFilterBar = document.getElementById('tags-filter-bar'); - const cardsList = document.getElementById('cards-list'); - const emptyState = document.getElementById('empty-state'); - - // Elements - Batch Action Bar - const batchToggleBtn = document.getElementById('batch-toggle-btn'); - const batchActionBar = document.getElementById('batch-action-bar'); - const batchSelectAll = document.getElementById('batch-select-all'); - const batchCountLabel = document.getElementById('batch-count-label'); - const batchMoveBtn = document.getElementById('batch-move-btn'); - const batchTagBtn = document.getElementById('batch-tag-btn'); - const batchDeleteBtn = document.getElementById('batch-delete-btn'); - const batchCancelBtn = document.getElementById('batch-cancel-btn'); - - // Elements - SRS Trainer View - const srsExitBtn = document.getElementById('srs-exit-btn'); - const srsTabs = document.querySelectorAll('.srs-tab'); - const srsProgressFill = document.getElementById('srs-progress-fill'); - const srsCounter = document.getElementById('srs-counter'); - const srsDirBtn = document.getElementById('srs-dir-btn'); - const srsDirLabel = document.getElementById('srs-dir-label'); - const srsMixBtn = document.getElementById('srs-mix-btn'); - const srsMixLabel = document.getElementById('srs-mix-label'); - - const srsLoading = document.getElementById('srs-loading'); - const srsEmpty = document.getElementById('srs-empty'); - const srsTrainAllBtn = document.getElementById('srs-train-all-btn'); - const srsEmptyBackBtn = document.getElementById('srs-empty-back-btn'); - - // Mode 1: Flashcards - const srsModeFlashcards = document.getElementById('srs-mode-flashcards'); - const flashcardFlipWrap = document.getElementById('flashcard-flip-wrap'); - const flashcardInner = document.getElementById('flashcard-inner'); - const fcFrontDirBadge = document.getElementById('fc-front-dir-badge'); - const fcFrontPos = document.getElementById('fc-front-pos'); - const fcFrontMain = document.getElementById('fc-front-main'); - const fcFrontPhoneticWrap = document.getElementById('fc-front-phonetic-wrap'); - const fcFrontPhonetic = document.getElementById('fc-front-phonetic'); - const fcFrontAudioBtn = document.getElementById('fc-front-audio-btn'); - const fcHintBtn = document.getElementById('fc-hint-btn'); - const fcHintBox = document.getElementById('fc-hint-box'); - const fcBackPos = document.getElementById('fc-back-pos'); - const fcBackTerm = document.getElementById('fc-back-term'); - const fcBackAudioBtn = document.getElementById('fc-back-audio-btn'); - const fcBackTranslation = document.getElementById('fc-back-translation'); - const fcBackDefBox = document.getElementById('fc-back-def-box'); - const fcBackDef = document.getElementById('fc-back-def'); - const fcBackContextBox = document.getElementById('fc-back-context-box'); - const fcBackContext = document.getElementById('fc-back-context'); - const fcBackNotesBox = document.getElementById('fc-back-notes-box'); - const fcBackNotes = document.getElementById('fc-back-notes'); - const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap'); - const fcFlipBtn = document.getElementById('fc-flip-btn'); - const fcRatingBar = document.getElementById('fc-rating-bar'); - - // Mode 2: Multiple Choice Quiz - const srsModeQuiz = document.getElementById('srs-mode-quiz'); - const quizPos = document.getElementById('quiz-pos'); - const quizTermDisplay = document.getElementById('quiz-term-display'); - const quizAudioBtn = document.getElementById('quiz-audio-btn'); - const quizPhoneticDisplay = document.getElementById('quiz-phonetic-display'); - const quizContextHint = document.getElementById('quiz-context-hint'); - const quizOptionsGrid = document.getElementById('quiz-options-grid'); - const quizFeedbackBox = document.getElementById('quiz-feedback-box'); - const quizDetailsBox = document.getElementById('quiz-details-box'); - const quizNextBar = document.getElementById('quiz-next-bar'); - const quizNextBtn = document.getElementById('quiz-next-btn'); - - // Mode 3: Active Typing - const srsModeTyping = document.getElementById('srs-mode-typing'); - const typingPos = document.getElementById('typing-pos'); - const typingPromptRu = document.getElementById('typing-prompt-ru'); - const typingContextHint = document.getElementById('typing-context-hint'); - const typingForm = document.getElementById('typing-form'); - const typingInput = document.getElementById('typing-input'); - const typingFeedbackBox = document.getElementById('typing-feedback-box'); - const typingDetailsBox = document.getElementById('typing-details-box'); - const typingRatingBar = document.getElementById('typing-rating-bar'); - - // Mode 4: Cloze - const srsModeCloze = document.getElementById('srs-mode-cloze'); - const clozePos = document.getElementById('cloze-pos'); - const clozeSentenceDisplay = document.getElementById('cloze-sentence-display'); - const clozeTargetTranslation = document.getElementById('cloze-target-translation'); - const clozeForm = document.getElementById('cloze-form'); - const clozeInput = document.getElementById('cloze-input'); - const clozeFeedbackBox = document.getElementById('cloze-feedback-box'); - const clozeDetailsBox = document.getElementById('cloze-details-box'); - const clozeRatingBar = document.getElementById('cloze-rating-bar'); - - // Mode 5: Dictation - const srsModeDictation = document.getElementById('srs-mode-dictation'); - const dictationReplayBtn = document.getElementById('dictation-replay-btn'); - const dictationBigPlayBtn = document.getElementById('dictation-big-play-btn'); - const dictationToggleHintBtn = document.getElementById('dictation-toggle-hint-btn'); - const dictationHintText = document.getElementById('dictation-hint-text'); - const dictationForm = document.getElementById('dictation-form'); - const dictationInput = document.getElementById('dictation-input'); - const dictationFeedbackBox = document.getElementById('dictation-feedback-box'); - const dictationDetailsBox = document.getElementById('dictation-details-box'); - const dictationRatingBar = document.getElementById('dictation-rating-bar'); - - // SRS Summary - const srsSummary = document.getElementById('srs-summary'); - const sumCount = document.getElementById('sum-count'); - const sumRetention = document.getElementById('sum-retention'); - const sumStreak = document.getElementById('sum-streak'); - const sumRatesBreakdown = document.getElementById('sum-rates-breakdown'); - const sumRepeatBtn = document.getElementById('sum-repeat-btn'); - const sumReturnBtn = document.getElementById('sum-return-btn'); - - // Modals - Stats / Heatmap - const statsModal = document.getElementById('stats-modal'); - const closeStatsModalBtn = document.getElementById('close-stats-modal-btn'); - const closeStatsBtn = document.getElementById('close-stats-btn'); - const kpiCurrentStreak = document.getElementById('kpi-current-streak'); - const kpiLongestStreak = document.getElementById('kpi-longest-streak'); - const kpiRetentionRate = document.getElementById('kpi-retention-rate'); - const kpiTotalReviews = document.getElementById('kpi-total-reviews'); - const kpiTotalWords = document.getElementById('kpi-total-words'); - const kpiTodayAdded = document.getElementById('kpi-today-added'); - const kpiCefrLevel = document.getElementById('kpi-cefr-level'); - const kpiCefrDesc = document.getElementById('kpi-cefr-desc'); - const heatmapGrid = document.getElementById('heatmap-grid'); - const progMastered = document.getElementById('prog-mastered'); - const progReview = document.getElementById('prog-review'); - const progLearning = document.getElementById('prog-learning'); - const progNew = document.getElementById('prog-new'); - const cntMastered = document.getElementById('cnt-mastered'); - const cntReview = document.getElementById('cnt-review'); - const cntLearning = document.getElementById('cnt-learning'); - const cntNew = document.getElementById('cnt-new'); - const cntLeeches = document.getElementById('cnt-leeches'); - - // Modals - Edit Card - const editModal = document.getElementById('edit-modal'); - const closeModalBtn = document.getElementById('close-modal-btn'); - const modalCancelBtn = document.getElementById('modal-cancel-btn'); - const modalSaveBtn = document.getElementById('modal-save-btn'); - const modalCardId = document.getElementById('modal-card-id'); - const modalTerm = document.getElementById('modal-term'); - const modalTranslation = document.getElementById('modal-translation'); - const modalContext = document.getElementById('modal-context'); - const modalPhonetic = document.getElementById('modal-phonetic'); - const modalPos = document.getElementById('modal-pos'); - const modalDef = document.getElementById('modal-def'); - const modalCollection = document.getElementById('modal-collection'); - const modalTags = document.getElementById('modal-tags'); - const modalNotes = document.getElementById('modal-notes'); - - // Modals - Export - const exportModal = document.getElementById('export-modal'); - const closeExportModalBtn = document.getElementById('close-export-modal-btn'); - const exportCancelBtn = document.getElementById('export-cancel-btn'); - - // Modals - Move Collection & New Collection - const moveColModal = document.getElementById('move-col-modal'); - const closeMoveModalBtn = document.getElementById('close-move-modal-btn'); - const moveColCancelBtn = document.getElementById('move-col-cancel-btn'); - const moveColConfirmBtn = document.getElementById('move-col-confirm-btn'); - const moveColInput = document.getElementById('move-col-input'); - const moveColQuickList = document.getElementById('move-col-quick-list'); - - const newColModal = document.getElementById('new-col-modal'); - const closeNewColModalBtn = document.getElementById('close-new-col-modal-btn'); - const newColCancelBtn = document.getElementById('new-col-cancel-btn'); - const newColSaveBtn = document.getElementById('new-col-save-btn'); - const newColNameInput = document.getElementById('new-col-name-input'); - - // Modals - Change Password - const changePasswordModal = document.getElementById('change-password-modal'); - const closeChangePwdBtn = document.getElementById('close-change-pwd-btn'); - const changePwdCancelBtn = document.getElementById('change-pwd-cancel-btn'); - const changePasswordForm = document.getElementById('change-password-form'); - const currentPasswordInput = document.getElementById('current-password-input'); - const newPasswordInput = document.getElementById('new-password-input'); - const confirmPasswordInput = document.getElementById('confirm-password-input'); - const changePwdError = document.getElementById('change-pwd-error'); - const changePwdSuccess = document.getElementById('change-pwd-success'); - - // Audio player - const globalAudioPlayer = document.getElementById('global-audio-player'); - - // ----------------------------------------------------------------------- - // Theme Management - // ----------------------------------------------------------------------- - const savedTheme = localStorage.getItem('notes_theme') || 'dark'; - document.documentElement.setAttribute('data-theme', savedTheme); - - themeToggle.addEventListener('click', () => { - const current = document.documentElement.getAttribute('data-theme'); - const next = current === 'dark' ? 'light' : 'dark'; - document.documentElement.setAttribute('data-theme', next); - localStorage.setItem('notes_theme', next); - }); - - // ----------------------------------------------------------------------- - // Audio Player Helper (Disabled) - // ----------------------------------------------------------------------- - function playAudio(url) { - // Audio playback disabled +// ----------------------------------------------------------------------- +// View Transitions +// ----------------------------------------------------------------------- +export function openDictionaryView(updateHash = true) { + if (updateHash && window.location.hash !== '#dictionary') { + window.location.hash = '#dictionary'; return; } + const dictionaryView = document.getElementById('dictionary-view'); + const srsView = document.getElementById('srs-view'); + const readerView = document.getElementById('reader-view'); - // ----------------------------------------------------------------------- - // Immediate Pre-flight Routing (Zero-flicker view restoration on F5) - // ----------------------------------------------------------------------- - function applyInitialViewPreflight() { + if (srsView) srsView.classList.add('hidden'); + if (readerView) readerView.classList.add('hidden'); + if (dictionaryView) dictionaryView.classList.remove('hidden'); + updateMobileActiveTab('dict'); + try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {} + loadInitialData(); +} + +export function openSrsView(tagFilter = null, updateHash = true) { + const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs'; + if (updateHash && window.location.hash !== targetHash) { + window.location.hash = targetHash; + return; + } + const dictionaryView = document.getElementById('dictionary-view'); + const srsView = document.getElementById('srs-view'); + const readerView = document.getElementById('reader-view'); + + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (readerView) readerView.classList.add('hidden'); + if (srsView) srsView.classList.remove('hidden'); + updateMobileActiveTab('srs'); + try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {} + startSrsTrainingSession(false, tagFilter); +} + +export function closeSrsView() { + openDictionaryView(true); +} + +export function openBookshelfView(updateHash = true) { + if (updateHash && window.location.hash !== '#reader') { + window.location.hash = '#reader'; + return; + } + const dictionaryView = document.getElementById('dictionary-view'); + const srsView = document.getElementById('srs-view'); + const readerView = document.getElementById('reader-view'); + const bookshelfContainer = document.getElementById('bookshelf-container'); + const readerInterface = document.getElementById('reader-interface'); + + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (srsView) srsView.classList.add('hidden'); + if (readerView) readerView.classList.remove('hidden'); + if (bookshelfContainer) bookshelfContainer.classList.remove('hidden'); + if (readerInterface) readerInterface.classList.add('hidden'); + updateMobileActiveTab('reader'); + try { + localStorage.setItem('notes_active_view', 'reader-view'); + localStorage.removeItem('notes_active_book_id'); + } catch (e) {} + loadBookshelf(); +} + +export function openReaderView(updateHash = true) { + const lastBookId = localStorage.getItem('notes_active_book_id'); + if (lastBookId) { + window.location.hash = `#reader/book/${lastBookId}`; + } else { + openBookshelfView(updateHash); + } +} + +// ----------------------------------------------------------------------- +// Route Handler +// ----------------------------------------------------------------------- +export async function handleRouting() { + if (isRouting) return; + isRouting = true; + try { let hash = window.location.hash || ''; if (!hash || hash === '#') { try { @@ -403,3562 +154,359 @@ document.addEventListener('DOMContentLoaded', () => { const lastBookId = localStorage.getItem('notes_active_book_id'); if (lastView === 'reader-view') { hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader'; + window.location.hash = hash; + return; } else if (lastView === 'srs-view') { hash = '#srs'; - } - } catch (e) {} - } - - if (hash.startsWith('#reader') || hash === '#bookshelf') { - if (dictionaryView) dictionaryView.classList.add('hidden'); - if (srsView) srsView.classList.add('hidden'); - if (readerView) readerView.classList.remove('hidden'); - updateMobileActiveTab('reader'); - if (hash.includes('/book/')) { - if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); - if (readerInterface) readerInterface.classList.remove('hidden'); - if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; - } - } else if (hash.startsWith('#srs')) { - if (dictionaryView) dictionaryView.classList.add('hidden'); - if (readerView) readerView.classList.add('hidden'); - if (srsView) srsView.classList.remove('hidden'); - updateMobileActiveTab('srs'); - } - } - applyInitialViewPreflight(); - - // ----------------------------------------------------------------------- - // Session & Auth Check - // ----------------------------------------------------------------------- - async function checkAuth() { - try { - const resp = await fetch('/api/me'); - if (resp.ok) { - const data = await resp.json(); - currentUser = data.user; - showApp(); - await handleRouting(); - loadInitialData(); - } else { - showLogin(); - } - } catch (e) { - showLogin(); - } - } - - function showLogin() { - loginOverlay.classList.remove('hidden'); - if (appShell) appShell.classList.add('hidden'); - loginUsername.focus(); - } - - function showApp() { - loginOverlay.classList.add('hidden'); - if (appShell) appShell.classList.remove('hidden'); - const name = currentUser ? currentUser.username : 'user'; - userDisplayName.textContent = name; - const mobileUserName = document.getElementById('mobile-user-name'); - if (mobileUserName) mobileUserName.textContent = name; - } - - loginForm.addEventListener('submit', async (e) => { - e.preventDefault(); - loginError.classList.add('hidden'); - try { - const resp = await fetch('/api/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: loginUsername.value.trim(), - password: loginPassword.value, - }), - }); - const data = await resp.json(); - if (resp.ok && data.status === 'ok') { - if (data.token) { - localStorage.setItem('notes_token', data.token); - } - currentUser = data.user; - showApp(); - await handleRouting(); - loadInitialData(); - } else { - loginError.textContent = data.detail || 'Неверный логин или пароль'; - loginError.classList.remove('hidden'); - } - } catch (err) { - loginError.textContent = 'Ошибка сетевого соединения'; - loginError.classList.remove('hidden'); - } - }); - - logoutBtn.addEventListener('click', async () => { - localStorage.removeItem('notes_token'); - localStorage.removeItem('notes_active_book_id'); - try { await fetch('/api/logout', { method: 'POST' }); } catch (e) {} - currentUser = null; - window.location.hash = ''; - window.location.reload(); - }); - - - // ----------------------------------------------------------------------- - // Data Loading: Cards, Collections, Tags, Stats - // ----------------------------------------------------------------------- - async function loadInitialData() { - await Promise.all([ - fetchCards(), - fetchCollections(), - fetchTags(), - fetchStats(), - fetchSrsStats(), - ]); - } - - async function fetchCards() { - try { - let url = `/api/cards?limit=500`; - if (selectedCollection) url += `&collection=${encodeURIComponent(selectedCollection)}`; - if (selectedTag) url += `&tag=${encodeURIComponent(selectedTag)}`; - if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`; - - const resp = await fetch(url); - if (resp.ok) { - const data = await resp.json(); - allCards = data.cards || []; - renderCardsList(); - } - } catch (e) { - console.error('Error fetching cards:', e); - } - } - - async function fetchCollections() { - try { - const resp = await fetch('/api/collections'); - if (resp.ok) { - const cols = await resp.json(); - collectionsList = cols.map(c => c.name); - renderCollectionsFilter(cols); - populateDatalists(); - } - } catch (e) { - console.error('Error fetching collections:', e); - } - } - - async function fetchTags() { - try { - const resp = await fetch('/api/tags'); - if (resp.ok) { - const tags = await resp.json(); - renderTagsFilter(tags); - } - } catch (e) { - console.error('Error fetching tags:', e); - } - } - - async function fetchStats() { - try { - const resp = await fetch('/api/stats'); - if (resp.ok) { - const data = await resp.json(); - statTotal.textContent = data.total_cards; - statToday.textContent = `+${data.today_added}`; - } - } catch (e) {} - } - - async function fetchSrsStats() { - try { - const resp = await fetch('/api/srs/stats'); - if (resp.ok) { - const stats = await resp.json(); - const due = stats.due_cards || 0; - srsBadge.textContent = due; - const tabSrsBadge = document.getElementById('tab-srs-badge'); - if (tabSrsBadge) { - tabSrsBadge.textContent = due; - if (due > 0) { - tabSrsBadge.classList.remove('hidden'); - } else { - tabSrsBadge.classList.add('hidden'); - } - } - } - } catch (e) {} - } - - // ----------------------------------------------------------------------- - // Duplicate Detection & Search Term - // ----------------------------------------------------------------------- - let duplicateCheckTimer = null; - let foundDuplicateCard = null; - - termInput.addEventListener('input', () => { - const val = termInput.value.trim(); - clearSearchBtn.classList.toggle('hidden', val.length === 0); - - clearTimeout(duplicateCheckTimer); - if (val.length >= 2) { - duplicateCheckTimer = setTimeout(async () => { - try { - const resp = await fetch(`/api/cards/check-duplicate?term=${encodeURIComponent(val)}`); - if (resp.ok) { - const res = await resp.json(); - if (res.exists && res.card) { - foundDuplicateCard = res.card; - dupTermLabel.textContent = res.card.term; - dupTransLabel.textContent = res.card.translation; - duplicateWarningBox.classList.remove('hidden'); - } else { - foundDuplicateCard = null; - duplicateWarningBox.classList.add('hidden'); - } - } - } catch (e) {} - }, 300); - } else { - foundDuplicateCard = null; - duplicateWarningBox.classList.add('hidden'); - } - }); - - dupOpenBtn.addEventListener('click', () => { - if (foundDuplicateCard) { - openEditModal(foundDuplicateCard); - } - }); - - clearSearchBtn.addEventListener('click', () => { - termInput.value = ''; - clearSearchBtn.classList.add('hidden'); - duplicateWarningBox.classList.add('hidden'); - lookupCard.classList.add('hidden'); - termInput.focus(); - }); - - // ----------------------------------------------------------------------- - // Dictionary Lookup & Capture - // ----------------------------------------------------------------------- - translateBtn.addEventListener('click', performLookup); - termInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - performLookup(); - } else if (e.key === 'Escape') { - termInput.value = ''; - clearSearchBtn.classList.add('hidden'); - duplicateWarningBox.classList.add('hidden'); - lookupCard.classList.add('hidden'); - } - }); - - async function performLookup() { - const term = termInput.value.trim(); - if (!term) return; - - translateBtn.disabled = true; - translateBtn.textContent = 'Ищем...'; - - try { - const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`); - if (resp.ok) { - const data = await resp.json(); - currentLookupData = data; - displayLookupResult(data); - } - } catch (e) { - console.error('Lookup error:', e); - } finally { - translateBtn.disabled = false; - translateBtn.textContent = 'Перевести'; - } - } - - function displayLookupResult(data) { - resTerm.textContent = data.term; - resPhonetic.textContent = data.phonetic || ''; - resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none'; - resPos.textContent = data.part_of_speech || 'Phrase'; - audioPlayBtn.classList.add('hidden'); - - if (data.definition_en) { - resDefinition.textContent = data.definition_en; - resDefinitionWrap.classList.remove('hidden'); - } else { - resDefinitionWrap.classList.add('hidden'); - } - - const inLib = Boolean(data.in_library || foundDuplicateCard); - if (resInLibrary) { - resInLibrary.classList.toggle('hidden', !inLib); - } - - if (inLib) { - saveCardBtn.innerHTML = '⭐ Обновить в словаре (Ctrl + Enter)'; - const lib = data.library_card || foundDuplicateCard; - if (lib) { - if (lib.translation) editTranslation.value = lib.translation; - if (lib.context_sentence) editContext.value = lib.context_sentence; - if (lib.collection) editCollection.value = lib.collection; - if (lib.tags) editTags.value = lib.tags; - if (lib.notes) editNotes.value = lib.notes; - } else { - editTranslation.value = data.translation || ''; - } - } else { - saveCardBtn.innerHTML = '💾 Сохранить в словарь (Ctrl + Enter)'; - editTranslation.value = data.translation || ''; - editContext.value = ''; - editNotes.value = ''; - editCollection.value = selectedCollection || 'General'; - } - - lookupCard.classList.remove('hidden'); - editTranslation.focus(); - } - - - // Save Card Action - saveCardBtn.addEventListener('click', saveNewCard); - document.addEventListener('keydown', (e) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { - if (!lookupCard.classList.contains('hidden')) { - e.preventDefault(); - saveNewCard(); - } - } - }); - - async function saveNewCard() { - if (!currentLookupData) return; - const term = currentLookupData.term.trim(); - const translation = editTranslation.value.trim(); - if (!translation) { - alert('Пожалуйста, укажите русский перевод'); - editTranslation.focus(); - return; - } - - const payload = { - term: term, - translation: translation, - context_sentence: editContext.value.trim(), - phonetic: currentLookupData.phonetic || '', - audio_url: currentLookupData.audio_url || '', - part_of_speech: currentLookupData.part_of_speech || '', - definition_en: currentLookupData.definition_en || '', - tags: editTags.value.trim() || 'general', - notes: editNotes.value.trim(), - collection: editCollection.value.trim() || 'General', - }; - - saveCardBtn.disabled = true; - saveCardBtn.textContent = 'Сохраняем...'; - - try { - const resp = await fetch('/api/cards', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (resp.ok) { - termInput.value = ''; - clearSearchBtn.classList.add('hidden'); - duplicateWarningBox.classList.add('hidden'); - lookupCard.classList.add('hidden'); - currentLookupData = null; - - await loadInitialData(); - termInput.focus(); - } else { - const err = await resp.json(); - alert('Ошибка сохранения: ' + (err.detail || 'Не удалось сохранить')); - } - } catch (e) { - alert('Сетевая ошибка при сохранении'); - } finally { - saveCardBtn.disabled = false; - saveCardBtn.innerHTML = '💾 Сохранить в словарь (Ctrl + Enter)'; - } - } - - // ----------------------------------------------------------------------- - // Cards List & Filtering - // ----------------------------------------------------------------------- - filterInput.addEventListener('input', () => { - searchQuery = filterInput.value.trim(); - fetchCards(); - }); - - function renderCollectionsFilter(cols) { - collectionsFilterBar.innerHTML = ''; - const allBtn = document.createElement('button'); - allBtn.className = `col-chip ${selectedCollection === '' ? 'active' : ''}`; - allBtn.textContent = 'Все коллекции'; - allBtn.onclick = () => { - selectedCollection = ''; - updateFilterChips(); - fetchCards(); - }; - collectionsFilterBar.appendChild(allBtn); - - cols.forEach(c => { - const chip = document.createElement('button'); - chip.className = `col-chip ${selectedCollection === c.name ? 'active' : ''}`; - chip.textContent = `${c.name} (${c.count})`; - chip.onclick = () => { - selectedCollection = c.name; - updateFilterChips(); - fetchCards(); - }; - collectionsFilterBar.appendChild(chip); - }); - } - - function renderTagsFilter(tags) { - tagsFilterBar.innerHTML = ''; - const allBtn = document.createElement('button'); - allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`; - allBtn.textContent = 'Все теги'; - allBtn.onclick = () => { - selectedTag = ''; - updateFilterChips(); - fetchCards(); - }; - tagsFilterBar.appendChild(allBtn); - - tags.slice(0, 15).forEach(t => { - const chip = document.createElement('button'); - chip.className = `filter-chip ${selectedTag === t.tag ? 'active' : ''}`; - chip.textContent = `#${t.tag} (${t.count})`; - chip.onclick = () => { - selectedTag = selectedTag === t.tag ? '' : t.tag; - updateFilterChips(); - fetchCards(); - }; - tagsFilterBar.appendChild(chip); - }); - } - - function updateFilterChips() { - document.querySelectorAll('.col-chip').forEach(el => { - el.classList.toggle('active', el.textContent.startsWith(selectedCollection || 'Все коллекции')); - }); - document.querySelectorAll('.filter-chip').forEach(el => { - el.classList.toggle('active', el.textContent.startsWith(`#${selectedTag}`) || (!selectedTag && el.textContent.startsWith('Все теги'))); - }); - } - - function renderCardsList() { - cardsCountLabel.textContent = `(${allCards.length} записей)`; - cardsList.innerHTML = ''; - - if (allCards.length === 0) { - emptyState.classList.remove('hidden'); - cardsList.appendChild(emptyState); - return; - } - - emptyState.classList.add('hidden'); - - allCards.forEach(c => { - const cardEl = document.createElement('div'); - cardEl.className = `vocab-card ${isBatchMode && selectedIds.has(c.id) ? 'is-selected' : ''}`; - - // SRS state indicator dot - const srsStateClass = c.srs_state === 'mastered' ? 'dot-mastered' : (c.srs_state === 'review' ? 'dot-review' : (c.srs_state === 'learning' ? 'dot-learning' : 'dot-new')); - - cardEl.innerHTML = ` -
-
- ${isBatchMode ? `` : ''} - - ${escapeHtml(c.term)} - ${c.phonetic ? `${escapeHtml(c.phonetic)}` : ''} - ${c.part_of_speech ? `${escapeHtml(c.part_of_speech)}` : ''} -
-
- - -
-
-
${escapeHtml(c.translation)}
- ${c.context_sentence ? `
“${escapeHtml(c.context_sentence)}”
` : ''} -
-
- 📁 ${escapeHtml(c.collection || 'General')} - ${c.tags ? c.tags.split(',').map(t => `#${escapeHtml(t.trim())}`).join(' ') : ''} -
- ${c.notes ? `
💡 ${escapeHtml(c.notes)}
` : ''} -
- `; - - // Edit button - cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => { - e.stopPropagation(); - openEditModal(c); - }); - - // Delete button - cardEl.querySelector('.delete-card-btn').addEventListener('click', async (e) => { - e.stopPropagation(); - if (confirm(`Удалить слово "${c.term}" из словаря?`)) { - await fetch(`/api/cards/${c.id}`, { method: 'DELETE' }); - await loadInitialData(); - } - }); - - // Batch selection click - if (isBatchMode) { - cardEl.addEventListener('click', () => { - toggleBatchSelection(c.id); - }); - } - - cardsList.appendChild(cardEl); - }); - } - - // ----------------------------------------------------------------------- - // Batch Operations - // ----------------------------------------------------------------------- - batchToggleBtn.addEventListener('click', () => { - isBatchMode = !isBatchMode; - batchToggleBtn.textContent = isBatchMode ? '✓ Завершить выбор' : '☑️ Выбрать'; - batchToggleBtn.classList.toggle('btn-primary', isBatchMode); - batchToggleBtn.classList.toggle('btn-outline', !isBatchMode); - batchActionBar.classList.toggle('hidden', !isBatchMode); - if (!isBatchMode) { - selectedIds.clear(); - } - renderCardsList(); - updateBatchCount(); - }); - - batchCancelBtn.addEventListener('click', () => { - isBatchMode = false; - batchToggleBtn.textContent = '☑️ Выбрать'; - batchToggleBtn.classList.remove('btn-primary'); - batchToggleBtn.classList.add('btn-outline'); - batchActionBar.classList.add('hidden'); - selectedIds.clear(); - renderCardsList(); - }); - - function toggleBatchSelection(id) { - if (selectedIds.has(id)) selectedIds.delete(id); - else selectedIds.add(id); - updateBatchCount(); - renderCardsList(); - } - - batchSelectAll.addEventListener('change', () => { - if (batchSelectAll.checked) { - allCards.forEach(c => selectedIds.add(c.id)); - } else { - selectedIds.clear(); - } - updateBatchCount(); - renderCardsList(); - }); - - function updateBatchCount() { - batchCountLabel.textContent = `Выбрано: ${selectedIds.size}`; - batchSelectAll.checked = selectedIds.size > 0 && selectedIds.size === allCards.length; - } - - batchDeleteBtn.addEventListener('click', async () => { - if (selectedIds.size === 0) return; - if (confirm(`Удалить выбранные карточки (${selectedIds.size} шт.)?`)) { - await fetch('/api/cards/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - card_ids: Array.from(selectedIds), - action: 'delete', - }), - }); - selectedIds.clear(); - await loadInitialData(); - } - }); - - batchMoveBtn.addEventListener('click', () => { - if (selectedIds.size === 0) { - alert('Сначала выберите хотя бы одну карточку'); - return; - } - moveColModal.classList.remove('hidden'); - }); - - batchTagBtn.addEventListener('click', async () => { - if (selectedIds.size === 0) return; - const tagVal = prompt('Введите тег(и) для добавления (через запятую):'); - if (tagVal && tagVal.trim()) { - await fetch('/api/cards/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - card_ids: Array.from(selectedIds), - action: 'add_tags', - value: tagVal.trim(), - }), - }); - await loadInitialData(); - } - }); - - // ----------------------------------------------------------------------- - // Interactive SRS Trainer & View Switching (Hash Router Driven) - // ----------------------------------------------------------------------- - srsTrainerBtn.addEventListener('click', () => { - window.location.hash = '#srs'; - }); - - srsExitBtn.addEventListener('click', () => { - window.location.hash = '#dictionary'; - }); - - srsMixBtn.addEventListener('click', () => { - srsSmartMix = !srsSmartMix; - srsMixLabel.textContent = srsSmartMix ? '🎲 Микс дня: ВКЛ' : '📚 Обычная очередь'; - srsMixBtn.classList.toggle('text-success', srsSmartMix); - startSrsTrainingSession(false); - }); - - srsTabs.forEach(tab => { - tab.addEventListener('click', () => { - srsTabs.forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - srsCurrentMode = tab.dataset.mode; - renderSrsCurrentCard(); - }); - }); - - srsDirBtn.addEventListener('click', () => { - srsDirection = srsDirection === 'en-ru' ? 'ru-en' : 'en-ru'; - srsDirLabel.textContent = srsDirection === 'en-ru' ? 'EN ➔ RU' : 'RU ➔ EN'; - renderSrsCurrentCard(); - }); - - function updateMobileActiveTab(tabName) { - document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => { - btn.classList.remove('active'); - }); - const target = document.getElementById(`tab-nav-${tabName}`); - if (target) target.classList.add('active'); - } - - function openDictionaryView(updateHash = true) { - if (updateHash && window.location.hash !== '#dictionary') { - window.location.hash = '#dictionary'; - return; - } - srsView.classList.add('hidden'); - if (readerView) readerView.classList.add('hidden'); - dictionaryView.classList.remove('hidden'); - updateMobileActiveTab('dict'); - try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {} - loadInitialData(); - } - - function openSrsView(tagFilter = null, updateHash = true) { - const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs'; - if (updateHash && window.location.hash !== targetHash) { - window.location.hash = targetHash; - return; - } - dictionaryView.classList.add('hidden'); - 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); - } - - function closeSrsView() { - openDictionaryView(true); - } - - function openBookshelfView(updateHash = true) { - if (updateHash && window.location.hash !== '#reader') { - window.location.hash = '#reader'; - return; - } - dictionaryView.classList.add('hidden'); - srsView.classList.add('hidden'); - if (readerView) readerView.classList.remove('hidden'); - if (bookshelfContainer) bookshelfContainer.classList.remove('hidden'); - if (readerInterface) readerInterface.classList.add('hidden'); - updateMobileActiveTab('reader'); - try { - localStorage.setItem('notes_active_view', 'reader-view'); - localStorage.removeItem('notes_active_book_id'); - } catch (e) {} - loadBookshelf(); - } - - function openReaderView(updateHash = true) { - const lastBookId = localStorage.getItem('notes_active_book_id'); - if (lastBookId) { - window.location.hash = `#reader/book/${lastBookId}`; - } else { - openBookshelfView(updateHash); - } - } - - let isRouting = false; - async function handleRouting() { - if (isRouting) return; - isRouting = true; - try { - let hash = window.location.hash || ''; - if (!hash || hash === '#') { - try { - const lastView = localStorage.getItem('notes_active_view'); - const lastBookId = localStorage.getItem('notes_active_book_id'); - if (lastView === 'reader-view') { - hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader'; - window.location.hash = hash; - return; - } else if (lastView === 'srs-view') { - hash = '#srs'; - window.location.hash = hash; - return; - } else { - hash = '#dictionary'; - } - } catch (e) { + window.location.hash = hash; + return; + } else { hash = '#dictionary'; } + } catch (e) { + hash = '#dictionary'; } + } - const cleanHash = hash.replace(/^#\/?/, ''); + const cleanHash = hash.replace(/^#\/?/, ''); - if (!cleanHash || cleanHash === 'dictionary') { - openDictionaryView(false); - } else if (cleanHash.startsWith('srs')) { - const parts = cleanHash.split('/'); - const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null; - openSrsView(tag, false); - } else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') { - const parts = cleanHash.split('/'); - if (parts[1] === 'book' && parts[2]) { - const bookId = parseInt(parts[2], 10); - const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null; - await openBook(bookId, chIdx, false); - } else { - openBookshelfView(false); - } + if (!cleanHash || cleanHash === 'dictionary') { + openDictionaryView(false); + } else if (cleanHash.startsWith('srs')) { + const parts = cleanHash.split('/'); + const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null; + openSrsView(tag, false); + } else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') { + const parts = cleanHash.split('/'); + if (parts[1] === 'book' && parts[2]) { + const bookId = parseInt(parts[2], 10); + const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null; + await openBook(bookId, chIdx, false); } else { - openDictionaryView(false); + openBookshelfView(false); } - } finally { - isRouting = false; - } - } - - window.addEventListener('hashchange', handleRouting); - - function initMobileNavigation() { - const tabDict = document.getElementById('tab-nav-dict'); - const tabSrs = document.getElementById('tab-nav-srs'); - const tabReader = document.getElementById('tab-nav-reader'); - const tabStats = document.getElementById('tab-nav-stats'); - const tabMenu = document.getElementById('tab-nav-menu'); - - const mobileMenuModal = document.getElementById('mobile-menu-modal'); - const mobileMenuToggleBtn = document.getElementById('mobile-menu-toggle-btn'); - const mobileMenuCloseBtn = document.getElementById('mobile-menu-close-btn'); - - const mobileStatsBtn = document.getElementById('mobile-stats-btn'); - const mobileExportBtn = document.getElementById('mobile-export-btn'); - const mobilePwdBtn = document.getElementById('mobile-pwd-btn'); - const mobileThemeBtn = document.getElementById('mobile-theme-btn'); - const mobileLogoutBtn = document.getElementById('mobile-logout-btn'); - - if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; }); - if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; }); - if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; }); - - if (tabStats) { - tabStats.addEventListener('click', () => { - if (statsModal) { - statsModal.classList.remove('hidden'); - loadStatsAndHeatmap(); - } - }); - } - - function openMobileMenu() { - if (mobileMenuModal) { - const mobileUserName = document.getElementById('mobile-user-name'); - if (mobileUserName && currentUser) { - mobileUserName.textContent = currentUser.username; - } - mobileMenuModal.classList.remove('hidden'); - } - } - - function closeMobileMenu() { - if (mobileMenuModal) { - mobileMenuModal.classList.add('hidden'); - } - } - - if (tabMenu) tabMenu.addEventListener('click', openMobileMenu); - if (mobileMenuToggleBtn) mobileMenuToggleBtn.addEventListener('click', openMobileMenu); - if (mobileMenuCloseBtn) mobileMenuCloseBtn.addEventListener('click', closeMobileMenu); - - if (mobileMenuModal) { - mobileMenuModal.addEventListener('click', (e) => { - if (e.target === mobileMenuModal) closeMobileMenu(); - }); - } - - if (mobileStatsBtn) { - mobileStatsBtn.addEventListener('click', () => { - closeMobileMenu(); - if (statsModal) { - statsModal.classList.remove('hidden'); - loadStatsAndHeatmap(); - } - }); - } - - if (mobileExportBtn) { - mobileExportBtn.addEventListener('click', () => { - closeMobileMenu(); - if (exportModal) exportModal.classList.remove('hidden'); - }); - } - - if (mobilePwdBtn) { - mobilePwdBtn.addEventListener('click', () => { - closeMobileMenu(); - if (changePwdModal) changePwdModal.classList.remove('hidden'); - }); - } - - if (mobileThemeBtn) { - mobileThemeBtn.addEventListener('click', () => { - themeToggle.click(); - }); - } - - if (mobileLogoutBtn) { - mobileLogoutBtn.addEventListener('click', () => { - closeMobileMenu(); - logoutBtn.click(); - }); - } - } - - navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; }); - if (navReaderBtn) navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; }); - initMobileNavigation(); - - async function startSrsTrainingSession(forceAll = false, tagFilter = null) { - srsLoading.classList.remove('hidden'); - srsEmpty.classList.add('hidden'); - srsSummary.classList.add('hidden'); - hideAllSrsModeContainers(); - - srsSessionRatings = []; - srsCurrentIndex = 0; - - let queue = []; - try { - let url = forceAll - ? `/api/srs/queue?limit=50` - : `/api/srs/queue?limit=25&mix=${srsSmartMix}`; - if (tagFilter) { - url += `&tag=${encodeURIComponent(tagFilter)}`; - } - const resp = await fetch(url); - if (resp.ok) { - const data = await resp.json(); - queue = data.cards || []; - } - } catch (e) { - console.error('SRS Queue error:', e); - } - - if (queue.length === 0 && forceAll && allCards.length > 0) { - queue = [...allCards]; - } - - srsLoading.classList.add('hidden'); - - if (queue.length === 0) { - srsEmpty.classList.remove('hidden'); - return; - } - - srsQueue = queue; - renderSrsCurrentCard(); - } - - srsTrainAllBtn.addEventListener('click', () => startSrsTrainingSession(true)); - srsEmptyBackBtn.addEventListener('click', closeSrsView); - sumRepeatBtn.addEventListener('click', () => startSrsTrainingSession(true)); - sumReturnBtn.addEventListener('click', closeSrsView); - - function hideAllSrsModeContainers() { - srsModeFlashcards.classList.add('hidden'); - srsModeQuiz.classList.add('hidden'); - srsModeTyping.classList.add('hidden'); - srsModeCloze.classList.add('hidden'); - srsModeDictation.classList.add('hidden'); - } - - function renderSrsCurrentCard() { - if (srsCurrentIndex >= srsQueue.length) { - showSrsSummary(); - return; - } - - srsEmpty.classList.add('hidden'); - srsSummary.classList.add('hidden'); - hideAllSrsModeContainers(); - - const total = srsQueue.length; - const currentNum = srsCurrentIndex + 1; - srsCounter.textContent = `Карточка ${currentNum} из ${total}`; - const pct = Math.round(((currentNum - 1) / total) * 100); - srsProgressFill.style.width = `${pct}%`; - - const card = srsQueue[srsCurrentIndex]; - - if (srsCurrentMode === 'flashcards') { - renderFlashcardMode(card); - } else if (srsCurrentMode === 'quiz') { - renderQuizMode(card); - } else if (srsCurrentMode === 'typing') { - renderTypingMode(card); - } else if (srsCurrentMode === 'cloze') { - renderClozeMode(card); - } else if (srsCurrentMode === 'dictation') { - renderDictationMode(card); - } - } - - // MODE 1: Flashcards - function renderFlashcardMode(card) { - srsModeFlashcards.classList.remove('hidden'); - srsIsFlipped = false; - flashcardInner.classList.remove('is-flipped'); - fcRatingBar.classList.add('hidden'); - fcHintBox.classList.add('hidden'); - fcFlipBtnWrap.style.display = 'block'; - - if (srsDirection === 'en-ru') { - fcFrontDirBadge.textContent = 'EN ➔ RU'; - fcFrontMain.textContent = card.term; - fcFrontPos.textContent = card.part_of_speech || ''; - fcFrontPos.style.display = card.part_of_speech ? 'inline-block' : 'none'; - - if (card.phonetic) { - fcFrontPhonetic.textContent = card.phonetic; - fcFrontPhoneticWrap.style.display = 'flex'; - } else { - fcFrontPhoneticWrap.style.display = 'none'; - } - - fcHintBtn.style.display = card.context_sentence ? 'inline-flex' : 'none'; - fcHintBox.textContent = card.context_sentence ? `“${card.context_sentence}”` : ''; - - // Back - fcBackTerm.textContent = card.term; - fcBackTranslation.textContent = card.translation; - fcBackPos.textContent = card.part_of_speech || ''; - fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none'; } else { - fcFrontDirBadge.textContent = 'RU ➔ EN'; - fcFrontMain.textContent = card.translation; - fcFrontPos.textContent = card.part_of_speech || ''; - fcFrontPhoneticWrap.style.display = 'none'; - fcHintBtn.style.display = 'none'; - - // Back - fcBackTerm.textContent = card.term; - fcBackTranslation.textContent = card.translation; - fcBackPos.textContent = card.part_of_speech || ''; + openDictionaryView(false); } - - fcBackDef.textContent = card.definition_en || ''; - fcBackDefBox.style.display = card.definition_en ? 'block' : 'none'; - fcBackContext.textContent = card.context_sentence ? `“${card.context_sentence}”` : ''; - fcBackContextBox.style.display = card.context_sentence ? 'block' : 'none'; - fcBackNotes.textContent = card.notes || ''; - fcBackNotesBox.style.display = card.notes ? 'block' : 'none'; - - fcFrontAudioBtn.style.display = 'none'; - fcBackAudioBtn.style.display = 'none'; + } finally { + isRouting = false; } - - function flipFlashcard() { - if (srsIsFlipped) return; - srsIsFlipped = true; - flashcardInner.classList.add('is-flipped'); - fcRatingBar.classList.remove('hidden'); - fcFlipBtnWrap.style.display = 'none'; - } - - flashcardFlipWrap.addEventListener('click', flipFlashcard); - fcFlipBtn.addEventListener('click', flipFlashcard); - fcHintBtn.addEventListener('click', (e) => { - e.stopPropagation(); - fcHintBox.classList.toggle('hidden'); - }); - - // Rating Submission for Flashcards - document.querySelectorAll('.btn-rate').forEach(btn => { - btn.addEventListener('click', async (e) => { - e.stopPropagation(); - const rating = parseInt(btn.dataset.rating, 10); - await submitCardReview(rating); - }); - }); - - async function submitCardReview(rating) { - const card = srsQueue[srsCurrentIndex]; - if (!card) return; - - srsSessionRatings.push(rating); - - try { - await fetch('/api/srs/review', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ card_id: card.id, rating: rating }), - }); - } catch (e) { - console.error('Review submission error:', e); - } - - srsCurrentIndex++; - renderSrsCurrentCard(); - } - - // MODE 2: Multiple Choice Quiz (4 Options) - function renderQuizMode(card) { - srsModeQuiz.classList.remove('hidden'); - quizAnswered = false; - quizFeedbackBox.classList.add('hidden'); - quizDetailsBox.classList.add('hidden'); - quizNextBar.classList.add('hidden'); - - quizPos.textContent = card.part_of_speech || ''; - quizTermDisplay.textContent = card.term; - quizPhoneticDisplay.textContent = card.phonetic || ''; - quizPhoneticDisplay.style.display = card.phonetic ? 'block' : 'none'; - - if (card.context_sentence) { - quizContextHint.textContent = `“${card.context_sentence}”`; - quizContextHint.classList.remove('hidden'); - } else { - quizContextHint.classList.add('hidden'); - } - - quizAudioBtn.classList.add('hidden'); - - // Generate 4 options: target + 3 random from other cards or presets - const correctText = card.translation; - const candidates = allCards - .filter(c => c.id !== card.id && c.translation && c.translation !== correctText) - .map(c => c.translation); - - shuffleArray(candidates); - const options = [correctText, ...candidates.slice(0, 3)]; - while (options.length < 4) { - options.push(`Вариант ${options.length + 1}`); - } - shuffleArray(options); - quizCurrentOptions = options; - - quizOptionsGrid.innerHTML = ''; - options.forEach((optText, idx) => { - const btn = document.createElement('button'); - btn.className = 'quiz-option-btn'; - btn.innerHTML = ` - ${idx + 1} - ${escapeHtml(optText)} - `; - btn.onclick = () => handleQuizSelection(optText, correctText, card, btn); - quizOptionsGrid.appendChild(btn); - }); - } - - async function handleQuizSelection(selectedText, correctText, card, clickedBtn) { - if (quizAnswered) return; - quizAnswered = true; - - const isCorrect = selectedText === correctText; - const allOptionBtns = quizOptionsGrid.querySelectorAll('.quiz-option-btn'); - - allOptionBtns.forEach(btn => { - btn.disabled = true; - const text = btn.querySelector('.quiz-opt-text').textContent; - if (text === correctText) { - btn.classList.add('opt-correct'); - } else if (btn === clickedBtn && !isCorrect) { - btn.classList.add('opt-wrong'); - } - }); - - if (card.audio_url) playAudio(card.audio_url); - - if (isCorrect) { - quizFeedbackBox.className = 'trainer-feedback-box feedback-correct'; - quizFeedbackBox.innerHTML = '🎉 Отлично! Правильный ответ.'; - srsSessionRatings.push(4); - // Record rating 4 in backend - fetch('/api/srs/review', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ card_id: card.id, rating: 4 }), - }); - } else { - quizFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; - quizFeedbackBox.innerHTML = `❌ Ошибка! Правильно: ${escapeHtml(correctText)}`; - srsSessionRatings.push(1); - // Record rating 1 (Again) in backend - fetch('/api/srs/review', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ card_id: card.id, rating: 1 }), - }); - } - quizFeedbackBox.classList.remove('hidden'); - - if (card.definition_en || card.context_sentence) { - quizDetailsBox.innerHTML = ` - ${card.definition_en ? `

Толкование: ${escapeHtml(card.definition_en)}

` : ''} - ${card.context_sentence ? `

Контекст: “${escapeHtml(card.context_sentence)}”

` : ''} - `; - quizDetailsBox.classList.remove('hidden'); - } - - quizNextBar.classList.remove('hidden'); - quizNextBtn.focus(); - } - - quizNextBtn.addEventListener('click', () => { - srsCurrentIndex++; - renderSrsCurrentCard(); - }); - - // MODE 3: Active Typing (Ru -> En) - function renderTypingMode(card) { - srsModeTyping.classList.remove('hidden'); - typingFeedbackBox.classList.add('hidden'); - typingDetailsBox.classList.add('hidden'); - typingRatingBar.classList.add('hidden'); - - typingPos.textContent = card.part_of_speech || ''; - typingPromptRu.textContent = card.translation; - if (card.context_sentence) { - typingContextHint.textContent = `Контекст: “${card.context_sentence}”`; - typingContextHint.classList.remove('hidden'); - } else { - typingContextHint.classList.add('hidden'); - } - - typingInput.value = ''; - typingInput.disabled = false; - typingInput.focus(); - } - - typingForm.addEventListener('submit', (e) => { - e.preventDefault(); - const card = srsQueue[srsCurrentIndex]; - if (!card) return; - - const userInput = typingInput.value.trim().toLowerCase(); - const target = card.term.trim().toLowerCase(); - const isMatch = userInput === target; - - if (card.audio_url) playAudio(card.audio_url); - - typingInput.disabled = true; - - if (isMatch) { - typingFeedbackBox.className = 'trainer-feedback-box feedback-correct'; - typingFeedbackBox.innerHTML = `✅ Верно! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; - } else { - typingFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; - typingFeedbackBox.innerHTML = `❌ Не совсем: правильное написание — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; - } - typingFeedbackBox.classList.remove('hidden'); - - renderDynamicRatingBar(typingRatingBar, isMatch ? 3 : 1); - }); - - // MODE 4: Cloze Deletion - function renderClozeMode(card) { - srsModeCloze.classList.remove('hidden'); - clozeFeedbackBox.classList.add('hidden'); - clozeDetailsBox.classList.add('hidden'); - clozeRatingBar.classList.add('hidden'); - - clozePos.textContent = card.part_of_speech || ''; - clozeTargetTranslation.textContent = card.translation; - - const context = card.context_sentence || `The word is ${card.term}.`; - const re = new RegExp(escapeRegex(card.term), 'gi'); - const gapText = context.replace(re, '_____'); - clozeSentenceDisplay.innerHTML = gapText; - - clozeInput.value = ''; - clozeInput.disabled = false; - clozeInput.focus(); - } - - clozeForm.addEventListener('submit', (e) => { - e.preventDefault(); - const card = srsQueue[srsCurrentIndex]; - if (!card) return; - - const userInput = clozeInput.value.trim().toLowerCase(); - const target = card.term.trim().toLowerCase(); - const isMatch = userInput === target; - - if (card.audio_url) playAudio(card.audio_url); - clozeInput.disabled = true; - - if (isMatch) { - clozeFeedbackBox.className = 'trainer-feedback-box feedback-correct'; - clozeFeedbackBox.innerHTML = `✅ Верно! целевое слово: ${escapeHtml(card.term)}`; - } else { - clozeFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; - clozeFeedbackBox.innerHTML = `❌ Правильный ответ: ${escapeHtml(card.term)}`; - } - clozeFeedbackBox.classList.remove('hidden'); - - renderDynamicRatingBar(clozeRatingBar, isMatch ? 3 : 1); - }); - - // MODE 5: Dictation - function renderDictationMode(card) { - srsModeDictation.classList.remove('hidden'); - dictationFeedbackBox.classList.add('hidden'); - dictationDetailsBox.classList.add('hidden'); - dictationRatingBar.classList.add('hidden'); - dictationHintText.classList.add('hidden'); - dictationHintText.textContent = card.translation; - - dictationInput.value = ''; - dictationInput.disabled = false; - - const play = () => { if (card.audio_url) playAudio(card.audio_url); }; - dictationBigPlayBtn.onclick = play; - dictationReplayBtn.onclick = play; - play(); - - dictationInput.focus(); - } - - dictationToggleHintBtn.addEventListener('click', () => { - dictationHintText.classList.toggle('hidden'); - }); - - dictationForm.addEventListener('submit', (e) => { - e.preventDefault(); - const card = srsQueue[srsCurrentIndex]; - if (!card) return; - - const userInput = dictationInput.value.trim().toLowerCase(); - const target = card.term.trim().toLowerCase(); - const isMatch = userInput === target; - - dictationInput.disabled = true; - - if (isMatch) { - dictationFeedbackBox.className = 'trainer-feedback-box feedback-correct'; - dictationFeedbackBox.innerHTML = `✅ Идеально! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''} — ${escapeHtml(card.translation)}`; - } else { - dictationFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; - dictationFeedbackBox.innerHTML = `❌ Ошибка спеллинга: правильное слово — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; - } - dictationFeedbackBox.classList.remove('hidden'); - - renderDynamicRatingBar(dictationRatingBar, isMatch ? 3 : 1); - }); - - function renderDynamicRatingBar(container, defaultRating) { - container.innerHTML = ` - - - - - `; - container.classList.remove('hidden'); - - container.querySelectorAll('.btn-rate').forEach(btn => { - btn.onclick = async () => { - const r = parseInt(btn.dataset.rating, 10); - await submitCardReview(r); - }; - }); - } - - function showSrsSummary() { - hideAllSrsModeContainers(); - srsSummary.classList.remove('hidden'); - srsProgressFill.style.width = '100%'; - srsCounter.textContent = 'Тренировка завершена!'; - - const total = srsSessionRatings.length; - sumCount.textContent = total; - const goodCount = srsSessionRatings.filter(r => r >= 3).length; - const retention = total > 0 ? Math.round((goodCount / total) * 100) : 100; - sumRetention.textContent = `${retention}%`; - - fetchSrsStats(); - } - - // Hotkeys handler - document.addEventListener('keydown', (e) => { - if (!srsView.classList.contains('hidden')) { - if (srsCurrentMode === 'flashcards') { - if (e.code === 'Space' && !srsIsFlipped) { - e.preventDefault(); - flipFlashcard(); - } else if (srsIsFlipped && ['1', '2', '3', '4'].includes(e.key)) { - e.preventDefault(); - submitCardReview(parseInt(e.key, 10)); - } - } else if (srsCurrentMode === 'quiz' && !quizAnswered) { - if (['1', '2', '3', '4'].includes(e.key)) { - const idx = parseInt(e.key, 10) - 1; - const btns = quizOptionsGrid.querySelectorAll('.quiz-option-btn'); - if (btns[idx]) { - e.preventDefault(); - btns[idx].click(); - } - } - } else if (srsCurrentMode === 'quiz' && quizAnswered) { - if (e.key === 'Enter' || e.code === 'Space') { - e.preventDefault(); - quizNextBtn.click(); - } - } - } - }); - - // ----------------------------------------------------------------------- - // Analytics Dashboard & GitHub-Style Heatmap Modal - // ----------------------------------------------------------------------- - statsOpenBtn.addEventListener('click', openStatsDashboard); - closeStatsModalBtn.addEventListener('click', () => statsModal.classList.add('hidden')); - closeStatsBtn.addEventListener('click', () => statsModal.classList.add('hidden')); - - async function openStatsDashboard() { - statsModal.classList.remove('hidden'); - await Promise.all([loadStatsKpis(), loadActivityHeatmap()]); - } - - async function loadStatsKpis() { - try { - const resp = await fetch('/api/srs/stats'); - if (resp.ok) { - const s = await resp.json(); - kpiCurrentStreak.textContent = `🔥 ${s.streak_days}`; - kpiLongestStreak.textContent = `Рекорд: ${s.longest_streak} дн.`; - kpiRetentionRate.textContent = `${s.retention_rate}%`; - kpiTotalReviews.textContent = `Всего повторений: ${s.total_reviews}`; - kpiTotalWords.textContent = s.total_cards; - kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`; - - // Strict CEFR Level estimation (PLAN.md 4.3) - if (s.cefr) { - kpiCefrLevel.textContent = s.cefr.level; - kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`; - } else { - const count = s.total_cards; - let lvl = 'A1'; - let desc = 'Базовый словарный запас'; - if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; } - else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; } - else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; } - else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; } - kpiCefrLevel.textContent = lvl; - kpiCefrDesc.textContent = desc; - } - - // Mastery Progress Bar - cntMastered.textContent = s.mastered; - cntReview.textContent = s.review; - cntLearning.textContent = s.learning; - cntNew.textContent = s.new_cards; - cntLeeches.textContent = s.leech_count; - - const total = s.total_cards || 1; - progMastered.style.width = `${(s.mastered / total) * 100}%`; - progReview.style.width = `${(s.review / total) * 100}%`; - progLearning.style.width = `${(s.learning / total) * 100}%`; - progNew.style.width = `${(s.new_cards / total) * 100}%`; - } - } catch (e) { - console.error('Stats load error:', e); - } - } - - async function loadActivityHeatmap() { - try { - const resp = await fetch('/api/srs/activity-heatmap?days=180'); - if (resp.ok) { - const data = await resp.json(); - renderHeatmap(data.days); - } - } catch (e) { - console.error('Heatmap load error:', e); - } - } - - function renderHeatmap(days) { - heatmapGrid.innerHTML = ''; - if (!days || days.length === 0) return; - - days.forEach(d => { - const cell = document.createElement('div'); - cell.className = `heatmap-cell lvl-${d.level}`; - cell.title = `${d.date}: ${d.count} повторений / тестов`; - heatmapGrid.appendChild(cell); - }); - } - - // ----------------------------------------------------------------------- - // Edit Card Modal - // ----------------------------------------------------------------------- - function openEditModal(card) { - modalCardId.value = card.id; - modalTerm.value = card.term; - modalTranslation.value = card.translation; - modalContext.value = card.context_sentence || ''; - modalPhonetic.value = card.phonetic || ''; - modalPos.value = card.part_of_speech || ''; - modalDef.value = card.definition_en || ''; - modalCollection.value = card.collection || 'General'; - modalTags.value = card.tags || ''; - modalNotes.value = card.notes || ''; - editModal.classList.remove('hidden'); - } - - closeModalBtn.addEventListener('click', () => editModal.classList.add('hidden')); - modalCancelBtn.addEventListener('click', () => editModal.classList.add('hidden')); - - modalSaveBtn.addEventListener('click', async () => { - const id = modalCardId.value; - const payload = { - term: modalTerm.value.trim(), - translation: modalTranslation.value.trim(), - context_sentence: modalContext.value.trim(), - phonetic: modalPhonetic.value.trim(), - part_of_speech: modalPos.value.trim(), - definition_en: modalDef.value.trim(), - collection: modalCollection.value.trim() || 'General', - tags: modalTags.value.trim(), - notes: modalNotes.value.trim(), - }; - - const resp = await fetch(`/api/cards/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - +} + +// ----------------------------------------------------------------------- +// Session & Auth +// ----------------------------------------------------------------------- +export async function checkAuth() { + try { + const resp = await api.auth.me(); if (resp.ok) { - editModal.classList.add('hidden'); - await loadInitialData(); - } else { - alert('Ошибка при сохранении карточки'); - } - }); - - // ----------------------------------------------------------------------- - // Export Modal - // ----------------------------------------------------------------------- - exportOpenBtn.addEventListener('click', () => { - exportModal.classList.remove('hidden'); - }); - - closeExportModalBtn.addEventListener('click', () => exportModal.classList.add('hidden')); - exportCancelBtn.addEventListener('click', () => exportModal.classList.add('hidden')); - - document.querySelectorAll('.export-tile').forEach(tile => { - tile.addEventListener('click', () => { - const format = tile.dataset.format; - let url = `/api/export/${format}`; - if (selectedCollection) url += `?collection=${encodeURIComponent(selectedCollection)}`; - window.location.href = url; - }); - }); - - // ----------------------------------------------------------------------- - // Collections Management - // ----------------------------------------------------------------------- - newCollectionBtn.addEventListener('click', () => { - newColModal.classList.remove('hidden'); - newColNameInput.value = ''; - newColNameInput.focus(); - }); - - closeNewColModalBtn.addEventListener('click', () => newColModal.classList.add('hidden')); - newColCancelBtn.addEventListener('click', () => newColModal.classList.add('hidden')); - - newColSaveBtn.addEventListener('click', () => { - const name = newColNameInput.value.trim(); - if (name) { - selectedCollection = name; - newColModal.classList.add('hidden'); - fetchCards(); - } - }); - - closeMoveModalBtn.addEventListener('click', () => moveColModal.classList.add('hidden')); - moveColCancelBtn.addEventListener('click', () => moveColModal.classList.add('hidden')); - - moveColConfirmBtn.addEventListener('click', async () => { - const col = moveColInput.value.trim(); - if (col && selectedIds.size > 0) { - await fetch('/api/cards/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - card_ids: Array.from(selectedIds), - action: 'set_collection', - value: col, - }), - }); - moveColModal.classList.add('hidden'); - selectedIds.clear(); - await loadInitialData(); - } - }); - - function populateDatalists() { - collectionsDatalist.innerHTML = ''; - const dl = document.getElementById('move-collections-datalist'); - if (dl) dl.innerHTML = ''; - - collectionsList.forEach(c => { - const opt = document.createElement('option'); - opt.value = c; - collectionsDatalist.appendChild(opt); - if (dl) dl.appendChild(opt.cloneNode(true)); - }); - - if (moveColQuickList) { - moveColQuickList.innerHTML = ''; - collectionsList.forEach(c => { - const btn = document.createElement('button'); - btn.className = 'btn btn-xs btn-outline'; - btn.textContent = c; - btn.onclick = () => { moveColInput.value = c; }; - moveColQuickList.appendChild(btn); - }); - } - } - - // ----------------------------------------------------------------------- - // Change Password Modal - // ----------------------------------------------------------------------- - changePwdOpenBtn.addEventListener('click', () => { - changePasswordModal.classList.remove('hidden'); - changePwdError.classList.add('hidden'); - changePwdSuccess.classList.add('hidden'); - currentPasswordInput.value = ''; - newPasswordInput.value = ''; - confirmPasswordInput.value = ''; - currentPasswordInput.focus(); - }); - - closeChangePwdBtn.addEventListener('click', () => changePasswordModal.classList.add('hidden')); - changePwdCancelBtn.addEventListener('click', () => changePasswordModal.classList.add('hidden')); - - changePasswordForm.addEventListener('submit', async (e) => { - e.preventDefault(); - changePwdError.classList.add('hidden'); - changePwdSuccess.classList.add('hidden'); - - if (newPasswordInput.value !== confirmPasswordInput.value) { - changePwdError.textContent = 'Новые пароли не совпадают'; - changePwdError.classList.remove('hidden'); - return; - } - - try { - const resp = await fetch('/api/change-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - old_password: currentPasswordInput.value, - new_password: newPasswordInput.value, - }), - }); const data = await resp.json(); - if (resp.ok) { - changePwdSuccess.textContent = 'Пароль успешно обновлен!'; - changePwdSuccess.classList.remove('hidden'); - setTimeout(() => changePasswordModal.classList.add('hidden'), 1200); - } else { - changePwdError.textContent = data.detail || 'Ошибка смены пароля'; - changePwdError.classList.remove('hidden'); - } - } catch (err) { - changePwdError.textContent = 'Ошибка сетевого соединения'; - changePwdError.classList.remove('hidden'); + currentUser = data.user; + showApp(); + await handleRouting(); + loadInitialData(); + } else { + showLogin(); } - }); - - // ======================================================================= - // STAGE 5: Interactive Reader & Bookshelf Implementation - // ======================================================================= - - function slugify(text) { - if (!text) return 'book'; - return text.toString().toLowerCase().trim() - .replace(/[^\w\s-]/g, '') - .replace(/[\s_-]+/g, '-') - .replace(/^-+|-+$/g, ''); + } catch (e) { + showLogin(); } +} - async function loadBookshelf() { - if (!bookshelfContainer) return; - if (!window.location.hash.includes('/book/')) { - bookshelfContainer.classList.remove('hidden'); - if (readerInterface) readerInterface.classList.add('hidden'); - } - if (bookshelfGrid) bookshelfGrid.innerHTML = ''; - if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden'); +export function showLogin() { + const loginOverlay = document.getElementById('login-overlay'); + const appShell = document.getElementById('app-shell'); + const loginUsername = document.getElementById('login-username'); - try { - const resp = await fetch('/api/books'); - if (resp.ok) { - const books = await resp.json(); - if (!books || books.length === 0) { - if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden'); - } else { - renderBookshelf(books); - } - } - } catch (e) { - console.error('Error loading bookshelf:', e); - } - } + if (loginOverlay) loginOverlay.classList.remove('hidden'); + if (appShell) appShell.classList.add('hidden'); + if (loginUsername) loginUsername.focus(); +} - function renderBookshelf(books) { - if (!bookshelfGrid) return; - bookshelfGrid.innerHTML = ''; - books.forEach(b => { - const card = document.createElement('div'); - card.className = 'book-card'; +export function showApp() { + const loginOverlay = document.getElementById('login-overlay'); + const appShell = document.getElementById('app-shell'); + const userDisplayName = document.getElementById('user-display-name'); + const mobileUserName = document.getElementById('mobile-user-name'); - const totalCh = Math.max(1, b.chapters_count || 1); - const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0)); - const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0; + if (loginOverlay) loginOverlay.classList.add('hidden'); + if (appShell) appShell.classList.remove('hidden'); + const name = currentUser ? currentUser.username : 'user'; + if (userDisplayName) userDisplayName.textContent = name; + if (mobileUserName) mobileUserName.textContent = name; +} - const progressFraction = (chIndex + pos) / totalCh; - const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100))); - const chPct = Math.min(100, Math.max(0, Math.round(pos * 100))); +// ----------------------------------------------------------------------- +// Mobile Navigation & Drawer +// ----------------------------------------------------------------------- +function initMobileNavigation() { + const tabDict = document.getElementById('tab-nav-dict'); + const tabSrs = document.getElementById('tab-nav-srs'); + const tabReader = document.getElementById('tab-nav-reader'); + const tabStats = document.getElementById('tab-nav-stats'); + const tabMenu = document.getElementById('tab-nav-menu'); - const progressLabels = totalCh === 1 - ? `1 глава • ${totalPct}%${totalPct}%` - : `Глава ${chIndex + 1} из ${totalCh} (${chPct}%)${totalPct}%`; + const mobileMenuModal = document.getElementById('mobile-menu-modal'); + const mobileMenuToggleBtn = document.getElementById('mobile-menu-toggle-btn'); + const mobileMenuCloseBtn = document.getElementById('mobile-menu-close-btn'); - card.innerHTML = ` -
-
-
${b.source_type === 'epub' ? '📕' : (b.source_type === 'pdf' ? '📑' : '📄')}
-
-

${escapeHtml(b.title)}

-

${escapeHtml(b.author || 'Неизвестный автор')}

-
- ${escapeHtml((b.source_type || 'text').toUpperCase())} - ${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''} -
-
-
-
-
- ${progressLabels} -
-
-
-
-
-
-
- - - -
- `; + const mobileStatsBtn = document.getElementById('mobile-stats-btn'); + const mobileExportBtn = document.getElementById('mobile-export-btn'); + const mobilePwdBtn = document.getElementById('mobile-pwd-btn'); + const mobileThemeBtn = document.getElementById('mobile-theme-btn'); + const mobileLogoutBtn = document.getElementById('mobile-logout-btn'); - card.querySelector('.read-book-btn').addEventListener('click', () => { - window.location.hash = `#reader/book/${b.id}`; - }); - card.querySelector('.train-book-btn').addEventListener('click', () => { - const bookTag = slugify(b.title); - window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; - }); - card.querySelector('.delete-book-btn').addEventListener('click', async () => { - if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) { - await fetch(`/api/books/${b.id}`, { method: 'DELETE' }); - loadBookshelf(); - } - }); + const exportModal = document.getElementById('export-modal'); + const changePasswordModal = document.getElementById('change-password-modal'); + const themeToggle = document.getElementById('theme-toggle'); + const logoutBtn = document.getElementById('logout-btn'); - bookshelfGrid.appendChild(card); + if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; }); + if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; }); + if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; }); + + if (tabStats) { + tabStats.addEventListener('click', () => { + openStatsDashboard(); }); } - async function openBook(bookId, specificChapterIdx = null, updateHash = true) { - if (!bookId) return; - - if (readerView) readerView.classList.remove('hidden'); - if (dictionaryView) dictionaryView.classList.add('hidden'); - if (srsView) srsView.classList.add('hidden'); - if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); - if (readerInterface) readerInterface.classList.remove('hidden'); - updateMobileActiveTab('reader'); - - if (currentBook && currentBook.id === bookId) { - if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { - if (specificChapterIdx !== currentChapterIndex) { - if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(specificChapterIdx)) { - currentChapterIndex = specificChapterIdx; - if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; - const target = document.getElementById(`chapter-anchor-${specificChapterIdx}`) - || readerContentBody.querySelector(`[data-chapter-idx="${specificChapterIdx}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } else { - renderChapter(specificChapterIdx, 0, 0, 0); - } - } + function openMobileMenu() { + if (mobileMenuModal) { + const mobileUserName = document.getElementById('mobile-user-name'); + if (mobileUserName && currentUser) { + mobileUserName.textContent = currentUser.username; } - if (updateHash) { - const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; - if (window.location.hash !== desiredHash) { - history.replaceState(null, '', desiredHash); - } - } - return; + openModal(mobileMenuModal); } + } - try { - if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; - const resp = await fetch(`/api/books/${bookId}`); - if (!resp.ok) { - alert('Не удалось открыть книгу'); - window.location.hash = '#reader'; + function closeMobileMenu() { + if (mobileMenuModal) { + closeModal(mobileMenuModal); + } + } + + if (tabMenu) tabMenu.addEventListener('click', openMobileMenu); + if (mobileMenuToggleBtn) mobileMenuToggleBtn.addEventListener('click', openMobileMenu); + if (mobileMenuCloseBtn) mobileMenuCloseBtn.addEventListener('click', closeMobileMenu); + + if (mobileMenuModal) { + mobileMenuModal.addEventListener('click', (e) => { + if (e.target === mobileMenuModal) closeMobileMenu(); + }); + } + + if (mobileStatsBtn) { + mobileStatsBtn.addEventListener('click', () => { + closeMobileMenu(); + openStatsDashboard(); + }); + } + + if (mobileExportBtn && exportModal) { + mobileExportBtn.addEventListener('click', () => { + closeMobileMenu(); + openModal(exportModal); + }); + } + + if (mobilePwdBtn && changePasswordModal) { + mobilePwdBtn.addEventListener('click', () => { + closeMobileMenu(); + openModal(changePasswordModal); + }); + } + + if (mobileThemeBtn && themeToggle) { + mobileThemeBtn.addEventListener('click', () => { + themeToggle.click(); + }); + } + + if (mobileLogoutBtn && logoutBtn) { + mobileLogoutBtn.addEventListener('click', () => { + closeMobileMenu(); + logoutBtn.click(); + }); + } +} + +// ----------------------------------------------------------------------- +// Password Change Modal +// ----------------------------------------------------------------------- +function initChangePasswordModal() { + const changePasswordModal = document.getElementById('change-password-modal'); + const closeChangePwdBtn = document.getElementById('close-change-pwd-btn'); + const changePwdCancelBtn = document.getElementById('change-pwd-cancel-btn'); + const changePwdOpenBtn = document.getElementById('change-pwd-open-btn'); + const changePasswordForm = document.getElementById('change-password-form'); + const currentPasswordInput = document.getElementById('current-password-input'); + const newPasswordInput = document.getElementById('new-password-input'); + const confirmPasswordInput = document.getElementById('confirm-password-input'); + const changePwdError = document.getElementById('change-pwd-error'); + const changePwdSuccess = document.getElementById('change-pwd-success'); + + if (changePwdOpenBtn && changePasswordModal) { + changePwdOpenBtn.addEventListener('click', () => { + if (changePwdError) changePwdError.classList.add('hidden'); + if (changePwdSuccess) changePwdSuccess.classList.add('hidden'); + if (currentPasswordInput) currentPasswordInput.value = ''; + if (newPasswordInput) newPasswordInput.value = ''; + if (confirmPasswordInput) confirmPasswordInput.value = ''; + openModal(changePasswordModal); + if (currentPasswordInput) currentPasswordInput.focus(); + }); + } + + if (closeChangePwdBtn && changePasswordModal) { + closeChangePwdBtn.addEventListener('click', () => closeModal(changePasswordModal)); + } + if (changePwdCancelBtn && changePasswordModal) { + changePwdCancelBtn.addEventListener('click', () => closeModal(changePasswordModal)); + } + + if (changePasswordForm) { + changePasswordForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (changePwdError) changePwdError.classList.add('hidden'); + if (changePwdSuccess) changePwdSuccess.classList.add('hidden'); + + const currentPwd = currentPasswordInput ? currentPasswordInput.value : ''; + const newPwd = newPasswordInput ? newPasswordInput.value : ''; + const confirmPwd = confirmPasswordInput ? confirmPasswordInput.value : ''; + + if (newPwd !== confirmPwd) { + if (changePwdError) { + changePwdError.textContent = 'Новые пароли не совпадают'; + changePwdError.classList.remove('hidden'); + } return; } - currentBook = await resp.json(); - - // Load saved reading position from localStorage or server - let targetChapter = currentBook.current_chapter || 0; - let targetScrollTop = null; - let targetFraction = currentBook.current_position || 0; - let targetAnchorIdx = null; 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; - targetAnchorIdx = parsed.anchorIdx; - } - } - } catch (e) {} - - if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { - targetChapter = specificChapterIdx; - if (specificChapterIdx !== (currentBook.current_chapter || 0)) { - targetScrollTop = 0; - targetFraction = 0; - targetAnchorIdx = 0; - } - } - - currentChapterIndex = Math.max(0, Math.min(targetChapter, (currentBook.chapters ? currentBook.chapters.length - 1 : 0))); - - readerBookTitle.textContent = currentBook.title; - readerBookAuthor.textContent = currentBook.author || ''; - - // Populate chapter selector with visual status markers - readerChapterSelect.innerHTML = ''; - const chapters = currentBook.chapters || []; - chapters.forEach((ch, idx) => { - const opt = document.createElement('option'); - opt.value = idx; - const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ '); - opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`; - readerChapterSelect.appendChild(opt); - }); - - applyReaderSettings(); - - try { - localStorage.setItem('notes_active_book_id', bookId); - localStorage.setItem('notes_active_view', 'reader-view'); - } catch (e) {} - - if (updateHash) { - const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; - if (window.location.hash !== desiredHash) { - history.replaceState(null, '', desiredHash); - } - } - - renderChapter(currentChapterIndex, targetScrollTop, targetFraction, targetAnchorIdx); - } catch (e) { - console.error('Error opening book:', e); - } - } - - function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false) { - const chapter = currentBook.chapters[chapterIdx] || { title: '', content: '' }; - const frag = document.createDocumentFragment(); - - if (chapter.title || isContinuous) { - if (isContinuous || chapterIdx > 0) { - const divider = document.createElement('div'); - divider.className = 'reader-chapter-divider'; - divider.id = `chapter-anchor-${chapterIdx}`; - divider.setAttribute('data-chapter-idx', chapterIdx.toString()); - - const badge = document.createElement('span'); - badge.className = 'divider-badge'; - badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`; - - const titleEl = document.createElement('h2'); - titleEl.className = 'divider-title'; - titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`; - - divider.appendChild(badge); - divider.appendChild(titleEl); - frag.appendChild(divider); - } else if (chapter.title) { - const h = document.createElement('h2'); - h.textContent = chapter.title; - h.setAttribute('data-block-idx', '0'); - h.setAttribute('data-chapter-idx', chapterIdx.toString()); - h.id = `chapter-anchor-${chapterIdx}`; - frag.appendChild(h); - } - } - - const blocks = parseContentBlocks(chapter.content); - - blocks.forEach((block, blockIdx) => { - const domIdx = blockIdx + 1; - let blockEl = null; - - if (block.type === 'code') { - blockEl = document.createElement('div'); - blockEl.className = 'reader-code-block'; - - const header = document.createElement('div'); - header.className = 'reader-code-header'; - - const langSpan = document.createElement('span'); - langSpan.className = 'reader-code-lang'; - langSpan.textContent = block.lang || 'code'; - - const copyBtn = document.createElement('button'); - copyBtn.className = 'reader-copy-code-btn'; - copyBtn.type = 'button'; - copyBtn.innerHTML = '📋 Копировать'; - copyBtn.onclick = async (e) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(block.code); - copyBtn.innerHTML = '✅ Скопировано!'; - setTimeout(() => { copyBtn.innerHTML = '📋 Копировать'; }, 2000); - } catch (err) { - console.error('Copy failed:', err); - } - }; - - header.appendChild(langSpan); - header.appendChild(copyBtn); - - const pre = document.createElement('pre'); - const codeEl = document.createElement('code'); - if (block.lang) codeEl.className = `language-${block.lang}`; - codeEl.textContent = block.code; - pre.appendChild(codeEl); - - blockEl.appendChild(header); - blockEl.appendChild(pre); - } else if (block.type === 'table') { - blockEl = document.createElement('div'); - blockEl.className = 'reader-table-wrap'; - const table = document.createElement('table'); - table.className = 'reader-table'; - - if (block.headers && block.headers.length > 0) { - const thead = document.createElement('thead'); - const headerTr = document.createElement('tr'); - block.headers.forEach((hText, colIdx) => { - const th = document.createElement('th'); - if (block.alignments && block.alignments[colIdx]) { - th.style.textAlign = block.alignments[colIdx]; - } - renderInteractiveSpans(th, hText, hText, knownWordsMap); - headerTr.appendChild(th); - }); - thead.appendChild(headerTr); - table.appendChild(thead); - } - - if (block.rows && block.rows.length > 0) { - const tbody = document.createElement('tbody'); - block.rows.forEach(row => { - const tr = document.createElement('tr'); - row.forEach((cellText, colIdx) => { - const td = document.createElement('td'); - if (block.alignments && block.alignments[colIdx]) { - td.style.textAlign = block.alignments[colIdx]; - } - renderInteractiveSpans(td, cellText, cellText, knownWordsMap); - tr.appendChild(td); - }); - tbody.appendChild(tr); - }); - table.appendChild(tbody); - } - blockEl.appendChild(table); - } else if (block.type === 'heading') { - const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`; - blockEl = document.createElement(tag); - if (block.align) blockEl.style.textAlign = block.align; - renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); - } else if (block.type === 'blockquote') { - blockEl = document.createElement('blockquote'); - renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); - } else if (block.type === 'ul' || block.type === 'ol') { - blockEl = document.createElement(block.type); - block.items.forEach(itemText => { - const li = document.createElement('li'); - renderInteractiveSpans(li, itemText, itemText, knownWordsMap); - blockEl.appendChild(li); - }); - } else if (block.type === 'hr') { - blockEl = document.createElement('hr'); - } else { - blockEl = document.createElement('p'); - if (block.align) { - blockEl.style.textAlign = block.align; - blockEl.classList.add('text-center'); - } - renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); - } - - if (blockEl) { - blockEl.setAttribute('data-block-idx', domIdx.toString()); - blockEl.setAttribute('data-chapter-idx', chapterIdx.toString()); - frag.appendChild(blockEl); - } - }); - - return frag; - } - - function buildChapterFooterDOM(chapterIdx) { - if (!currentBook || !currentBook.chapters) return null; - const totalCh = currentBook.chapters.length; - const isLastChapter = (chapterIdx >= totalCh - 1); - - const footer = document.createElement('div'); - footer.className = 'reader-chapter-footer'; - footer.id = `reader-chapter-footer-${chapterIdx}`; - footer.setAttribute('data-footer-for-ch', chapterIdx.toString()); - - if (isLastChapter) { - footer.classList.add('reader-book-finished'); - footer.innerHTML = ` - - - - `; - const trainBtn = footer.querySelector(`#footer-train-btn-${chapterIdx}`); - if (trainBtn) { - trainBtn.onclick = () => { - const tag = slugify(currentBook.title); - window.location.hash = `#srs/${encodeURIComponent(tag)}`; - }; - } - const shelfBtn = footer.querySelector(`#footer-shelf-btn-${chapterIdx}`); - if (shelfBtn) { - shelfBtn.onclick = () => { - window.location.hash = '#reader'; - }; - } - return footer; - } - - const nextCh = chapterIdx + 1; - const nextTitle = currentBook.chapters[nextCh]?.title || `Глава ${nextCh + 1}`; - - footer.innerHTML = ` - - - - `; - - const nextBtn = footer.querySelector(`#footer-next-btn-${chapterIdx}`); - if (nextBtn) { - nextBtn.onclick = () => { - if (readerSettings.flowMode === 'continuous') { - const target = document.getElementById(`chapter-anchor-${nextCh}`); - if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } else if (readerSettings.autoLoadNext) { - appendNextChapter(nextCh, true); - } else { - window.location.hash = `#reader/book/${currentBook.id}/ch/${nextCh}`; - } - }; - } - - const prevBtn = footer.querySelector(`#footer-prev-btn-${chapterIdx}`); - if (prevBtn) { - prevBtn.onclick = () => { - window.location.hash = `#reader/book/${currentBook.id}/ch/${chapterIdx - 1}`; - }; - } - - return footer; - } - - function appendNextChapter(nextCh, smoothScroll = false) { - if (!currentBook || !currentBook.chapters || nextCh >= currentBook.chapters.length) return; - if (loadedChaptersSet.has(nextCh)) { - if (smoothScroll) { - const target = document.getElementById(`chapter-anchor-${nextCh}`); - if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - return; - } - - const oldFooter = readerContentBody.querySelector(`.reader-chapter-footer[data-footer-for-ch="${nextCh - 1}"]`); - if (oldFooter) { - oldFooter.remove(); - } - - const knownWordsMap = new Map(); - allCards.forEach(c => { - if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); - }); - - const chFrag = buildChapterDOM(nextCh, knownWordsMap, true); - readerContentBody.appendChild(chFrag); - - const newFooter = buildChapterFooterDOM(nextCh); - if (newFooter) { - readerContentBody.appendChild(newFooter); - } - - loadedChaptersSet.add(nextCh); - - readerPrevChBtn.disabled = false; - readerNextChBtn.disabled = nextCh >= currentBook.chapters.length - 1; - - if (smoothScroll) { - const anchor = document.getElementById(`chapter-anchor-${nextCh}`); - if (anchor) { - anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - } - - function renderContinuousBook(targetChapter = 0, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) { - if (!currentBook || !currentBook.chapters) return; - - currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1)); - if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; - - readerPrevChBtn.disabled = currentChapterIndex <= 0; - readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; - - readerContentBody.innerHTML = ''; - loadedChaptersSet.clear(); - - const knownWordsMap = new Map(); - allCards.forEach(c => { - if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); - }); - - loadedChaptersSet.add(currentChapterIndex); - const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false); - readerContentBody.appendChild(chFrag); - - const footer = buildChapterFooterDOM(currentChapterIndex); - if (footer) { - readerContentBody.appendChild(footer); - } - - applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); - - 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: posToSave }), - }).catch(err => console.log('Progress save error:', err)); - } - - function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) { - if (!currentBook || !currentBook.chapters) return; - - if (readerSettings.flowMode === 'continuous') { - renderContinuousBook(index, restoreScrollTop, restoreFraction, restoreAnchorIdx); - return; - } - - currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1)); - readerChapterSelect.value = currentChapterIndex; - - // Refresh status icons in chapter select - if (readerChapterSelect.options) { - for (let i = 0; i < readerChapterSelect.options.length; i++) { - const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`; - const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ '); - readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`; - } - } - - readerPrevChBtn.disabled = currentChapterIndex <= 0; - readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; - - readerContentBody.innerHTML = ''; - loadedChaptersSet.clear(); - loadedChaptersSet.add(currentChapterIndex); - - const knownWordsMap = new Map(); - allCards.forEach(c => { - if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); - }); - - const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false); - readerContentBody.appendChild(chFrag); - - const footer = buildChapterFooterDOM(currentChapterIndex); - if (footer) { - readerContentBody.appendChild(footer); - } - - applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); - - // 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: posToSave }), - }).catch(err => console.log('Progress save error:', err)); - } - - function applyScroll(restoreAnchorIdx = null, restoreScrollTop = null, restoreFraction = null, chIdx = 0) { - if (!readerScrollArea) return; - - function doScroll() { - if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) { - const el = readerContentBody.querySelector(`[data-chapter-idx="${chIdx}"][data-block-idx="${restoreAnchorIdx}"]`) - || readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`); - if (el) { - const containerTop = readerScrollArea.getBoundingClientRect().top; - const elTop = el.getBoundingClientRect().top; - readerScrollArea.scrollTop += (elTop - containerTop - 10); - updateReaderProgressBar(); - 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 if (chIdx > 0) { - const anchor = document.getElementById(`chapter-anchor-${chIdx}`); - if (anchor) { - const containerTop = readerScrollArea.getBoundingClientRect().top; - const elTop = anchor.getBoundingClientRect().top; - readerScrollArea.scrollTop += (elTop - containerTop - 10); - } else { - readerScrollArea.scrollTop = 0; - } - } else { - readerScrollArea.scrollTop = 0; - } - updateReaderProgressBar(); - } - - requestAnimationFrame(() => { - doScroll(); - setTimeout(doScroll, 100); - }); - } - - // Parse text into structured semantic blocks - function parseContentBlocks(content) { - if (!content) return []; - const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); - const blocks = []; - - let i = 0; - while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); - - // 1. Fenced Code Block: ```lang ... ``` - if (trimmed.startsWith('```')) { - const lang = trimmed.slice(3).trim() || 'code'; - const codeLines = []; - i++; - while (i < lines.length && !lines[i].trim().startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - if (i < lines.length && lines[i].trim().startsWith('```')) { - i++; // skip closing ``` - } - blocks.push({ - type: 'code', - lang: lang, - code: codeLines.join('\n') - }); - continue; - } - - // 2. Empty line - if (!trimmed) { - i++; - continue; - } - - // 3. Markdown Table: lines starting with | and ending with | - if (trimmed.startsWith('|') && trimmed.endsWith('|')) { - const tableLines = []; - while (i < lines.length && lines[i].trim().startsWith('|')) { - tableLines.push(lines[i].trim()); - i++; - } - if (tableLines.length >= 2) { - const splitRow = (rowStr) => { - const inner = rowStr.replace(/^\|/, '').replace(/\|$/, ''); - return inner.split(/(? c.trim().replace(/\\\|/g, '|')); - }; - const headers = splitRow(tableLines[0]); - const alignRow = splitRow(tableLines[1]); - const alignments = alignRow.map(col => { - const tc = col.trim(); - if (tc.startsWith(':') && tc.endsWith(':')) return 'center'; - if (tc.endsWith(':')) return 'right'; - return 'left'; - }); - const rows = []; - for (let r = 2; r < tableLines.length; r++) { - rows.push(splitRow(tableLines[r])); - } - blocks.push({ - type: 'table', - headers: headers, - alignments: alignments, - rows: rows - }); - continue; - } - } - - // 4. Markdown Headings: #, ##, ###, ####, #####, ###### - const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/); - if (headingMatch) { - blocks.push({ - type: 'heading', - level: Math.min(6, Math.max(1, headingMatch[1].length)), - text: headingMatch[2].trim() - }); - i++; - continue; - } - - // HTML Heading: ... - const htmlHeadingMatch = line.match(/^]*)>(.*?)<\/h\1>/i); - if (htmlHeadingMatch) { - const level = parseInt(htmlHeadingMatch[1], 10); - const attrs = htmlHeadingMatch[2] || ''; - const hText = htmlHeadingMatch[3].trim(); - const align = /align=["']?center["']?/i.test(attrs) ? 'center' : ''; - blocks.push({ - type: 'heading', - level: level, - text: hText, - align: align - }); - i++; - continue; - } - - // 5. Horizontal Rule: --- or *** or ___ or
- if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '
' || trimmed === '
' || trimmed === '
') { - blocks.push({ type: 'hr' }); - i++; - continue; - } - - // 6. Blockquote: > ... - if (trimmed.startsWith('>')) { - const quoteLines = []; - while (i < lines.length && lines[i].trim().startsWith('>')) { - quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); - i++; - } - blocks.push({ - type: 'blockquote', - text: quoteLines.join(' ') - }); - continue; - } - - // 7. Unordered List: - item or * item or + item - if (/^[-*+]\s+/.test(trimmed)) { - const listItems = []; - while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { - listItems.push(lines[i].trim().replace(/^[-*+]\s+/, '')); - i++; - } - blocks.push({ - type: 'ul', - items: listItems - }); - continue; - } - - // 8. Ordered List: 1. item - if (/^\d+\.\s+/.test(trimmed)) { - const listItems = []; - while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { - listItems.push(lines[i].trim().replace(/^\d+\.\s+/, '')); - i++; - } - blocks.push({ - type: 'ol', - items: listItems - }); - continue; - } - - // 9. Centered HTML block (

or

) - if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) { - const htmlLines = []; - while (i < lines.length) { - const cur = lines[i]; - htmlLines.push(cur.trim()); - if (/<\/(?:p|div)>/i.test(cur)) { - i++; - break; - } - i++; - } - blocks.push({ - type: 'paragraph', - text: htmlLines.join(' '), - align: 'center' - }); - continue; - } - - // 10. Regular paragraph: gather lines until next block marker or empty line - const paraLines = []; - while (i < lines.length) { - const cur = lines[i]; - const curTrim = cur.trim(); - if (!curTrim) break; - if (curTrim.startsWith('```') || - /^#{1,6}\s+/.test(curTrim) || - /^]*>/i.test(curTrim) || - (curTrim.startsWith('|') && curTrim.endsWith('|')) || - curTrim.startsWith('>') || - /^[-*+]\s+/.test(curTrim) || - /^\d+\.\s+/.test(curTrim) || - /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) || - /^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) { - break; - } - paraLines.push(curTrim); - i++; - } - if (paraLines.length > 0) { - blocks.push({ - type: 'paragraph', - text: paraLines.join(' ') - }); - } - } - - return blocks; - } - - // Render text with full inline Markdown/HTML formatting and interactive clickable word spans - function renderInteractiveSpans(container, text, contextText, knownWordsMap) { - if (!text) return; - - // 1. Normalize entities & clean non-semantic wrapper tags - let processed = text - .replace(/·/g, '·') - .replace(/•/g, '•') - .replace(/—/g, '—') - .replace(/–/g, '–'); - - // Strip enclosing and

tags if present - processed = processed.replace(/^]*>/i, '').replace(/<\/p>$/i, ''); - // Strip wrapper picture and source tags - processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, ''); - - // 2. Convert Markdown inline patterns into HTML - // Images: ![alt](url) -> alt - processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); - // Links: [text](url) -> $1 - processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); - // Inline Code: `code` -> $1 - processed = processed.replace(/`([^`]+)`/g, '$1'); - // Bold: **text** or __text__ -> text - processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); - processed = processed.replace(/__([^_]+)__/g, '$1'); - // Italic: *text* or _text_ -> text - processed = processed.replace(/(?$1'); - processed = processed.replace(/(?$1'); - - // 3. Parse into DOM tree via native template fragment - const tpl = document.createElement('template'); - tpl.innerHTML = processed; - - // 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words - function transformDOMNode(node) { - if (node.nodeType === Node.TEXT_NODE) { - const raw = node.textContent; - if (!raw) return document.createTextNode(''); - const frag = document.createDocumentFragment(); - const tokens = raw.split(/([A-Za-z0-9'-]+)/); - for (const token of tokens) { - const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1; - if (isWord) { - const span = document.createElement('span'); - span.className = 'reader-word'; - span.dataset.word = token; - span.dataset.context = contextText || raw; - span.textContent = token; - - const lower = token.toLowerCase(); - if (knownWordsMap && knownWordsMap.has(lower)) { - const card = knownWordsMap.get(lower); - span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning'); - } - if (isHighlightEnabled) { - span.classList.add('highlight-enabled'); - } - - frag.appendChild(span); - } else { - frag.appendChild(document.createTextNode(token)); - } - } - return frag; - } - - if (node.nodeType === Node.ELEMENT_NODE) { - const tag = node.tagName.toLowerCase(); - - // Code and pre: keep as code badge, DO NOT tokenize words! - if (tag === 'code' || tag === 'pre') { - const codeEl = document.createElement('code'); - codeEl.className = 'reader-inline-code'; - codeEl.textContent = node.textContent; - return codeEl; - } - - // Images - if (tag === 'img') { - const img = document.createElement('img'); - img.className = 'reader-img'; - img.src = node.getAttribute('src') || ''; - img.alt = node.getAttribute('alt') || ''; - if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width')); - if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height')); - // Hide gracefully if broken/local relative asset - img.addEventListener('error', () => { - img.style.display = 'none'; - }); - return img; - } - - // Links - if (tag === 'a') { - const a = document.createElement('a'); - a.className = 'reader-link'; - a.href = node.getAttribute('href') || '#'; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - Array.from(node.childNodes).forEach(child => { - a.appendChild(transformDOMNode(child)); - }); - return a; - } - - if (tag === 'br') { - return document.createElement('br'); - } - - if (tag === 'strong' || tag === 'b') { - const strong = document.createElement('strong'); - Array.from(node.childNodes).forEach(child => { - strong.appendChild(transformDOMNode(child)); - }); - return strong; - } - - if (tag === 'em' || tag === 'i') { - const em = document.createElement('em'); - Array.from(node.childNodes).forEach(child => { - em.appendChild(transformDOMNode(child)); - }); - return em; - } - - if (tag === 'sub' || tag === 'sup') { - const el = document.createElement(tag); - Array.from(node.childNodes).forEach(child => { - el.appendChild(transformDOMNode(child)); - }); - return el; - } - - // Generic containers (span, div, etc.) - const containerEl = document.createElement(tag === 'p' ? 'span' : tag); - if (node.hasAttribute('style')) { - containerEl.setAttribute('style', node.getAttribute('style')); - } - Array.from(node.childNodes).forEach(child => { - containerEl.appendChild(transformDOMNode(child)); - }); - return containerEl; - } - - return document.createTextNode(''); - } - - Array.from(tpl.content.childNodes).forEach(childNode => { - container.appendChild(transformDOMNode(childNode)); - }); - } - - // Extract cleanest surrounding sentence for context - function extractContextSentence(fullText, word) { - if (!fullText) return ''; - const re = new RegExp(`([^.!?\\n]*?\\b${escapeRegex(word)}\\b[^.!?\\n]*[.!?]?)`, 'i'); - const m = fullText.match(re); - if (m && m[1]) return m[1].trim(); - return fullText.slice(0, 160).trim(); - } - - async function handleWordClick(word, paragraphText, targetElement) { - const sentence = extractContextSentence(paragraphText, word); - openWordPopup(word, sentence, targetElement); - } - - // Precise popup positioning near target element without scroll double-offset - function positionWordPopup(targetElement) { - if (!targetElement || !readerWordPopup) return; - if (window.innerWidth <= 768) { - readerWordPopup.style.top = ''; - readerWordPopup.style.left = ''; - return; - } - const rect = targetElement.getBoundingClientRect(); - const popupWidth = 320; - const popupHeight = readerWordPopup.offsetHeight || 220; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - // Center horizontally over target element - let left = rect.left + (rect.width / 2) - (popupWidth / 2); - if (left + popupWidth > viewportWidth - 12) { - left = viewportWidth - popupWidth - 12; - } - if (left < 12) { - left = 12; - } - - // Position vertically: default below the word - let top = rect.bottom + 8; - // If bottom overflows viewport, flip above the word - if (top + popupHeight > viewportHeight - 12) { - if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) { - top = Math.max(65, rect.top - popupHeight - 8); - } - } - - readerWordPopup.style.top = `${Math.round(top)}px`; - readerWordPopup.style.left = `${Math.round(left)}px`; - } - - async function openWordPopup(term, contextSentence, targetElement) { - const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim(); - if (!cleanTerm) return; - if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden'); - - const lowerClean = cleanTerm.toLowerCase(); - const existingCard = allCards && allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean); - - popupCurrentData = { - term: cleanTerm, - contextSentence: contextSentence, - targetElement: targetElement, - existingCard: existingCard, - }; - - popupTerm.textContent = cleanTerm; - popupPhonetic.textContent = ''; - popupTranslation.textContent = ''; - popupContext.textContent = contextSentence ? `“${contextSentence}”` : ''; - popupLoading.classList.remove('hidden'); - popupContent.classList.add('hidden'); - - // Immediate check against already-loaded user cards - if (popupInLibrary) { - popupInLibrary.classList.toggle('hidden', !existingCard); - } - - if (existingCard) { - popupSaveBtn.classList.add('hidden'); - popupSavedBadge.classList.remove('hidden'); - popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; - if (popupAddContextBtn && contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) { - popupAddContextBtn.classList.remove('hidden'); - } else if (popupAddContextBtn) { - popupAddContextBtn.classList.add('hidden'); - } - } else { - popupSavedBadge.classList.add('hidden'); - popupSaveBtn.classList.remove('hidden'); - popupSaveBtn.disabled = false; - if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); - } - - // Position popup near target element - readerWordPopup.classList.remove('hidden'); - positionWordPopup(targetElement); - - try { - const resp = await fetch(`/api/lookup?q=${encodeURIComponent(cleanTerm)}`); - if (resp.ok) { + const resp = await api.auth.changePassword(currentPwd, newPwd); const data = await resp.json(); - popupCurrentData.lookup = data; - - const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : ''); - popupPhonetic.textContent = data.phonetic || ''; - popupTranslation.textContent = trans || 'Перевод не найден'; - popupLoading.classList.add('hidden'); - popupContent.classList.remove('hidden'); - popupAudioBtn.classList.add('hidden'); - - const inLib = Boolean(data.in_library || existingCard); - const libCard = data.library_card || existingCard; - if (inLib) { - popupCurrentData.existingCard = libCard; - if (popupInLibrary) popupInLibrary.classList.remove('hidden'); - popupSaveBtn.classList.add('hidden'); - popupSavedBadge.classList.remove('hidden'); - popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; - if (popupAddContextBtn && contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) { - popupAddContextBtn.classList.remove('hidden'); - } else if (popupAddContextBtn) { - popupAddContextBtn.classList.add('hidden'); - } - } else { - if (popupInLibrary) popupInLibrary.classList.add('hidden'); - popupSaveBtn.classList.remove('hidden'); - popupSavedBadge.classList.add('hidden'); - if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); - } - - positionWordPopup(targetElement); - } else { - popupTranslation.textContent = 'Ошибка перевода'; - popupLoading.classList.add('hidden'); - popupContent.classList.remove('hidden'); - positionWordPopup(targetElement); - } - } catch (e) { - console.error('Popup lookup error:', e); - popupTranslation.textContent = 'Сетевая ошибка'; - popupLoading.classList.add('hidden'); - popupContent.classList.remove('hidden'); - positionWordPopup(targetElement); - } - } - - if (popupCloseBtn) { - popupCloseBtn.addEventListener('click', () => { - readerWordPopup.classList.add('hidden'); - }); - } - - document.addEventListener('click', (e) => { - if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) { - readerWordPopup.classList.add('hidden'); - } - }); - - function updatePopupPositionOnScroll() { - if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return; - if (window.innerWidth <= 768) return; // Mobile uses fixed bottom sheet - if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) { - readerWordPopup.classList.add('hidden'); - return; - } - const rect = popupCurrentData.targetElement.getBoundingClientRect(); - const headerOffset = 65; // reader toolbar sticky height - if (rect.bottom < headerOffset || rect.top > window.innerHeight) { - readerWordPopup.classList.add('hidden'); - return; - } - positionWordPopup(popupCurrentData.targetElement); - } - - let popupScrollRaf = null; - function schedulePopupUpdate() { - if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return; - if (popupScrollRaf) return; - popupScrollRaf = requestAnimationFrame(() => { - popupScrollRaf = null; - updatePopupPositionOnScroll(); - }); - } - - window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true }); - window.addEventListener('resize', schedulePopupUpdate, { passive: true }); - - if (popupSaveBtn) { - popupSaveBtn.addEventListener('click', async () => { - if (!popupCurrentData) return; - const d = popupCurrentData; - const lookup = d.lookup || {}; - const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; - - const payload = { - term: d.term, - translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent, - context_sentence: d.contextSentence || '', - phonetic: lookup.phonetic || '', - audio_url: lookup.audio_url || '', - part_of_speech: lookup.part_of_speech || '', - definition_en: lookup.definition_en || '', - collection: 'Книги & Сериалы', - tags: `${bookTag}, reader`, - notes: currentBook ? `Из книги: ${currentBook.title}` : '', - }; - - try { - const resp = await fetch('/api/cards', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (resp.ok) { - const resData = await resp.json(); - popupSaveBtn.classList.add('hidden'); - popupSavedBadge.classList.remove('hidden'); - popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!'; - if (popupInLibrary) popupInLibrary.classList.remove('hidden'); - if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); - - if (d.targetElement) { - d.targetElement.classList.add('status-learning'); - if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled'); + if (changePwdSuccess) { + changePwdSuccess.textContent = 'Пароль успешно обновлен!'; + changePwdSuccess.classList.remove('hidden'); } - - await fetchCards(); - await fetchTags(); + setTimeout(() => { + if (changePasswordModal) closeModal(changePasswordModal); + }, 1200); } else { - alert('Не удалось сохранить слово'); - } - } catch (e) { - console.error('Save card from reader error:', e); - } - }); - } - - if (popupAddContextBtn) { - popupAddContextBtn.addEventListener('click', async () => { - if (!popupCurrentData) return; - const d = popupCurrentData; - const lookup = d.lookup || {}; - const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; - const payload = { - term: d.term, - translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent, - context_sentence: d.contextSentence || '', - phonetic: lookup.phonetic || '', - audio_url: lookup.audio_url || '', - part_of_speech: lookup.part_of_speech || '', - definition_en: lookup.definition_en || '', - collection: 'Книги & Сериалы', - tags: `${bookTag}, reader`, - notes: currentBook ? `Из книги: ${currentBook.title}` : '', - }; - - popupAddContextBtn.disabled = true; - popupAddContextBtn.textContent = 'Сохраняем...'; - - try { - const resp = await fetch('/api/cards', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (resp.ok) { - popupAddContextBtn.classList.add('hidden'); - popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!'; - popupSavedBadge.classList.remove('hidden'); - await fetchCards(); - } else { - alert('Не удалось обновить контекст'); - } - } catch (e) { - console.error('Add context error:', e); - } finally { - popupAddContextBtn.disabled = false; - popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке'; - } - }); - } - - - // Phrase/Chunk Selection in Reader - if (readerContentBody) { - readerContentBody.addEventListener('mouseup', () => { - const sel = window.getSelection(); - const text = sel.toString().trim(); - if (text && text.split(/\s+/).length >= 2 && text.length <= 80) { - const range = sel.getRangeAt(0); - const rect = range.getBoundingClientRect(); - openWordPopup(text, text, { - getBoundingClientRect: () => rect, - }); - } - }); - } - - // Chapter Navigation & Reader Toolbar (URL Hash & Continuous Scroll Driven) - if (readerChapterSelect) { - readerChapterSelect.addEventListener('change', () => { - const ch = parseInt(readerChapterSelect.value, 10); - if (!currentBook) return; - if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(ch)) { - currentChapterIndex = ch; - const target = document.getElementById(`chapter-anchor-${ch}`) - || readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - } - window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`; - }); - } - - if (readerPrevChBtn) { - readerPrevChBtn.addEventListener('click', () => { - if (currentBook && currentChapterIndex > 0) { - const prev = currentChapterIndex - 1; - if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(prev)) { - currentChapterIndex = prev; - if (readerChapterSelect) readerChapterSelect.value = prev; - const target = document.getElementById(`chapter-anchor-${prev}`) - || readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; + if (changePwdError) { + changePwdError.textContent = data.detail || 'Ошибка смены пароля'; + changePwdError.classList.remove('hidden'); } } - window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`; - } - }); - } - - if (readerNextChBtn) { - readerNextChBtn.addEventListener('click', () => { - if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) { - const next = currentChapterIndex + 1; - if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(next)) { - currentChapterIndex = next; - if (readerChapterSelect) readerChapterSelect.value = next; - const target = document.getElementById(`chapter-anchor-${next}`) - || readerContentBody.querySelector(`[data-chapter-idx="${next}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - } - if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) { - appendNextChapter(next, true); - } else { - window.location.hash = `#reader/book/${currentBook.id}/ch/${next}`; - } - } - }); - } - - if (readerBackBtn) { - readerBackBtn.addEventListener('click', () => { - window.location.hash = '#reader'; - }); - } - - // Scroll Position Tracking, Paragraph Anchoring & Real-Time Weighted 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 getActiveAnchorIndex() { - if (!readerScrollArea || !readerContentBody) return null; - const blocks = readerContentBody.querySelectorAll('[data-block-idx]'); - if (!blocks || blocks.length === 0) return null; - const containerTop = readerScrollArea.getBoundingClientRect().top; - for (let el of blocks) { - const rect = el.getBoundingClientRect(); - if (rect.bottom >= containerTop + 10) { - return parseInt(el.getAttribute('data-block-idx'), 10); - } - } - return 0; - } - - function getActiveVisibleChapter() { - if (!readerScrollArea || !readerContentBody) return currentChapterIndex; - const dividers = readerContentBody.querySelectorAll('[data-chapter-idx]'); - if (!dividers || dividers.length === 0) return currentChapterIndex; - - const containerTop = readerScrollArea.getBoundingClientRect().top; - let activeIdx = currentChapterIndex; - - for (let el of dividers) { - const rect = el.getBoundingClientRect(); - if (rect.top <= containerTop + 140) { - const ch = parseInt(el.getAttribute('data-chapter-idx'), 10); - if (!isNaN(ch)) activeIdx = ch; - } else { - break; - } - } - return activeIdx; - } - - function updateReaderProgressBar() { - const { pct, fraction } = getReaderScrollMetrics(); - - if (readerSettings.flowMode === 'continuous') { - if (readerChapterProgressFill) { - readerChapterProgressFill.style.width = `${pct}%`; - } - if (readerScrollPctBadge) { - readerScrollPctBadge.textContent = `${pct}%`; - const totalCh = currentBook && currentBook.chapters ? currentBook.chapters.length : 1; - readerScrollPctBadge.title = `Вся книга: ${pct}% • Глава ${currentChapterIndex + 1} из ${totalCh}`; - } - return; - } - - // Chapters mode - if (readerChapterProgressFill) { - readerChapterProgressFill.style.width = `${pct}%`; - } - - if (currentBook) { - const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1)); - const overallFraction = (currentChapterIndex + fraction) / totalCh; - const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100))); - - if (readerScrollPctBadge) { - readerScrollPctBadge.textContent = `${pct}%`; - readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`; - } - } else if (readerScrollPctBadge) { - readerScrollPctBadge.textContent = `${pct}%`; - } - } - - function handleReaderScroll() { - if (!currentBook || !readerScrollArea) return; - updateReaderProgressBar(); - - // 1. Detect active visible chapter in continuous or multi-chapter view - if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.size > 1) { - const activeCh = getActiveVisibleChapter(); - if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) { - currentChapterIndex = activeCh; - if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; - if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0; - if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; - - const desiredHash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex}`; - if (window.location.hash !== desiredHash) { - history.replaceState(null, '', desiredHash); - } - } - } - - // 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload - if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) { - const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight); - if (distanceToBottom < 700) { - const loadedArr = Array.from(loadedChaptersSet); - const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex; - if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) { - isAppendingChapter = true; - appendNextChapter(highestCh + 1, false); - setTimeout(() => { isAppendingChapter = false; }, 200); - } - } - } - - const { scrollTop, fraction } = getReaderScrollMetrics(); - const anchorIdx = getActiveAnchorIndex(); - - // 3. Instant local persistence - try { - const key = `notes_book_${currentBook.id}_pos`; - localStorage.setItem(key, JSON.stringify({ - chapter: currentChapterIndex, - scrollTop: scrollTop, - fraction: fraction, - anchorIdx: anchorIdx, - savedAt: Date.now() - })); - localStorage.setItem('notes_active_book_id', currentBook.id); - localStorage.setItem('notes_active_view', 'reader-view'); - } catch (e) {} - - // 4. 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(); - const anchorIdx = getActiveAnchorIndex(); - try { - localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({ - chapter: currentChapterIndex, - scrollTop: scrollTop, - fraction: fraction, - anchorIdx: anchorIdx, - 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'); - if (saved) { - readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved)); - } - } catch (e) {} - } - - function saveReaderSettings() { - try { - localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings)); - } catch (e) {} - } - - function applyReaderSettings() { - if (!readerScrollArea) return; - const anchorIdx = getActiveAnchorIndex(); - - // 1. Font size - readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`); - if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; - - // 2. Line height - readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight); - - // 3. Theme - readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled'); - readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`); - - // 4. Font family - readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono'); - readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`); - - // 5. Margin width - readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide'); - readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`); - - // 6. Text align - readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify'); - readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`); - - updateReaderSettingsUI(); - - // Re-anchor scroll position after font/layout change - if (anchorIdx !== null && anchorIdx !== undefined) { - requestAnimationFrame(() => { - const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`); - if (el) { - const containerTop = readerScrollArea.getBoundingClientRect().top; - const elTop = el.getBoundingClientRect().top; - readerScrollArea.scrollTop += (elTop - containerTop - 10); - } - }); - } - } - - function updateReaderSettingsUI() { - if (!readerSettingsPanel) return; - - if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; - - if (readerFontFamilyGroup) { - readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily); - }); - } - - if (readerLineHeightGroup) { - readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05); - }); - } - - if (readerMarginsGroup) { - readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth); - }); - } - - if (readerThemeGroup) { - readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => { - btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme); - }); - } - - if (readerAlignGroup) { - readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign); - }); - } - - if (readerModeGroup) { - readerModeGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.flow === readerSettings.flowMode); - }); - } - - if (settingAutoloadRow) { - settingAutoloadRow.style.display = (readerSettings.flowMode === 'chapters') ? '' : 'none'; - } - - if (readerAutoloadGroup) { - readerAutoloadGroup.querySelectorAll('.seg-btn').forEach(btn => { - btn.classList.toggle('active', (btn.dataset.autoload === 'true') === readerSettings.autoLoadNext); - }); - } - } - - function positionSettingsPanel() { - if (!readerSettingsPanel || !readerSettingsBtn) return; - if (window.innerWidth <= 768) { - readerSettingsPanel.style.top = ''; - readerSettingsPanel.style.left = ''; - readerSettingsPanel.style.right = ''; - readerSettingsPanel.style.maxHeight = ''; - return; - } - const rect = readerSettingsBtn.getBoundingClientRect(); - const panelWidth = 330; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let left = rect.right - panelWidth; - if (left < 16) left = 16; - if (left + panelWidth > viewportWidth - 16) { - left = viewportWidth - panelWidth - 16; - } - - const top = Math.round(rect.bottom + 8); - const maxH = Math.max(260, viewportHeight - top - 16); - - readerSettingsPanel.style.top = `${top}px`; - readerSettingsPanel.style.left = `${Math.round(left)}px`; - readerSettingsPanel.style.right = 'auto'; - readerSettingsPanel.style.maxHeight = `${maxH}px`; - } - - function initReaderSettingsPanel() { - loadSavedReaderSettings(); - applyReaderSettings(); - - if (readerSettingsBtn) { - readerSettingsBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (readerWordPopup) readerWordPopup.classList.add('hidden'); - if (readerSettingsPanel) { - const isHidden = readerSettingsPanel.classList.contains('hidden'); - readerSettingsPanel.classList.toggle('hidden', !isHidden); - if (isHidden) { - updateReaderSettingsUI(); - positionSettingsPanel(); - } - } - }); - } - - if (readerSettingsCloseBtn) { - readerSettingsCloseBtn.addEventListener('click', () => { - if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden'); - }); - } - - if (settingFontDecBtn) { - settingFontDecBtn.addEventListener('click', () => { - if (readerSettings.fontSize > 13) { - readerSettings.fontSize -= 1; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (settingFontIncBtn) { - settingFontIncBtn.addEventListener('click', () => { - if (readerSettings.fontSize < 34) { - readerSettings.fontSize += 1; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerFontFamilyGroup) { - readerFontFamilyGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.font) { - readerSettings.fontFamily = btn.dataset.font; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerLineHeightGroup) { - readerLineHeightGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.lh) { - readerSettings.lineHeight = parseFloat(btn.dataset.lh); - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerMarginsGroup) { - readerMarginsGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.margin) { - readerSettings.marginWidth = btn.dataset.margin; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerThemeGroup) { - readerThemeGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.theme-chip'); - if (btn && btn.dataset.theme) { - readerSettings.theme = btn.dataset.theme; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerAlignGroup) { - readerAlignGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.align) { - readerSettings.textAlign = btn.dataset.align; - applyReaderSettings(); - saveReaderSettings(); - } - }); - } - - if (readerModeGroup) { - readerModeGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.flow) { - if (readerSettings.flowMode !== btn.dataset.flow) { - readerSettings.flowMode = btn.dataset.flow; - saveReaderSettings(); - updateReaderSettingsUI(); - if (currentBook) { - renderChapter(currentChapterIndex, 0, 0, 0); - } - } - } - }); - } - - if (readerAutoloadGroup) { - readerAutoloadGroup.addEventListener('click', (e) => { - const btn = e.target.closest('.seg-btn'); - if (btn && btn.dataset.autoload !== undefined) { - readerSettings.autoLoadNext = (btn.dataset.autoload === 'true'); - saveReaderSettings(); - updateReaderSettingsUI(); - const footerHint = readerContentBody.querySelector('.chapter-footer-hint'); - if (footerHint) { - footerHint.textContent = (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) - ? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз' - : '💡 Нажмите кнопку выше для перехода к следующей главе'; - } - } - }); - } - - document.addEventListener('click', (e) => { - if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) { - if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) { - readerSettingsPanel.classList.add('hidden'); - } - } - }); - - window.addEventListener('resize', () => { - if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) { - positionSettingsPanel(); - } - }, { passive: true }); - - window.addEventListener('scroll', () => { - if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden') && window.innerWidth > 768) { - positionSettingsPanel(); - } - }, { passive: true }); - } - - initReaderSettingsPanel(); - - if (readerContentBody) { - readerContentBody.addEventListener('click', (e) => { - const wordSpan = e.target.closest('.reader-word'); - if (wordSpan) { - e.stopPropagation(); - const word = wordSpan.dataset.word; - const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word; - handleWordClick(word, context, wordSpan); - } - }); - } - - if (readerToggleHighlightBtn) { - readerToggleHighlightBtn.addEventListener('click', () => { - isHighlightEnabled = !isHighlightEnabled; - readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled); - readerContentBody.querySelectorAll('.reader-word').forEach(w => { - w.classList.toggle('highlight-enabled', isHighlightEnabled); - }); - }); - } - - if (readerTrainBtn) { - readerTrainBtn.addEventListener('click', () => { - if (currentBook) { - const bookTag = slugify(currentBook.title); - window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; - } - }); - } - - // Bookshelf Add/Upload Actions - if (addTextBtn) { - addTextBtn.addEventListener('click', () => { - addBookForm.reset(); - addBookModal.classList.remove('hidden'); - }); - } - - if (closeAddBookModalBtn) closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden')); - if (cancelAddBookBtn) cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden')); - - if (addBookForm) { - addBookForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const title = bookTitleInput.value.trim(); - const author = bookAuthorInput.value.trim(); - const content = bookContentInput.value.trim(); - - if (!title || !content) return; - - try { - const resp = await fetch('/api/books', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: title, - author: author, - content: content, - source_type: 'text', - }), - }); - - if (resp.ok) { - const book = await resp.json(); - addBookModal.classList.add('hidden'); - window.location.hash = `#reader/book/${book.id}`; - } else { - alert('Ошибка при сохранении книги'); - } } catch (err) { - console.error('Book create error:', err); - } - }); - } - - if (bookFileInput) { - bookFileInput.addEventListener('change', async () => { - const file = bookFileInput.files[0]; - if (!file) return; - - const formData = new FormData(); - formData.append('file', file); - - try { - const resp = await fetch('/api/books', { - method: 'POST', - body: formData, - }); - - if (resp.ok) { - const book = await resp.json(); - bookFileInput.value = ''; - window.location.hash = `#reader/book/${book.id}`; - } else { - let errMsg = 'Не удалось обработать файл'; - try { - const errData = await resp.json(); - errMsg = errData.detail || errMsg; - } catch (_) { - if (resp.status === 413) { - errMsg = 'Файл слишком большой (превышен лимит размера в Nginx)'; - } else { - errMsg = `Код ответа сервера: HTTP ${resp.status}`; - } - } - alert(`Ошибка загрузки: ${errMsg}`); + if (changePwdError) { + changePwdError.textContent = 'Ошибка сетевого соединения'; + changePwdError.classList.remove('hidden'); + } + } + }); + } +} + +// ----------------------------------------------------------------------- +// Initialization & Entry Point +// ----------------------------------------------------------------------- +document.addEventListener('DOMContentLoaded', () => { + // 1. PWA Service Worker Registration + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js', { scope: '/' }) + .then(reg => console.log('Notes PWA ServiceWorker active:', reg.scope)) + .catch(err => console.log('Notes ServiceWorker failed:', err)); + } + + // 2. Pre-flight routing check to avoid DOM flicker on reload + applyInitialViewPreflight(); + + // 3. UI theme initialization + initTheme(); + + // 4. Initialize Feature Modules + initDictionary(); + initSrs(); + initReader(); + + // 5. Global Navigation & Header Actions + const navBrandHome = document.getElementById('nav-brand-home'); + const navReaderBtn = document.getElementById('nav-reader-btn'); + const srsTrainerBtn = document.getElementById('srs-trainer-btn'); + const loginForm = document.getElementById('login-form'); + const loginUsername = document.getElementById('login-username'); + const loginPassword = document.getElementById('login-password'); + const loginError = document.getElementById('login-error'); + const logoutBtn = document.getElementById('logout-btn'); + + if (navBrandHome) { + navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; }); + } + if (navReaderBtn) { + navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; }); + } + if (srsTrainerBtn) { + srsTrainerBtn.addEventListener('click', () => { window.location.hash = '#srs'; }); + } + + // 6. Mobile Tab Bar & Drawer + initMobileNavigation(); + + // 7. Change Password Modal + initChangePasswordModal(); + + // 8. Hash Change Listener + window.addEventListener('hashchange', handleRouting); + + // 9. Auth Actions + if (loginForm) { + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (loginError) loginError.classList.add('hidden'); + try { + const resp = await api.auth.login( + loginUsername ? loginUsername.value.trim() : '', + loginPassword ? loginPassword.value : '' + ); + const data = await resp.json(); + if (resp.ok && data.status === 'ok') { + if (data.token) { + localStorage.setItem('notes_token', data.token); + } + currentUser = data.user; + showApp(); + await handleRouting(); + loadInitialData(); + } else { + if (loginError) { + loginError.textContent = data.detail || 'Неверный логин или пароль'; + loginError.classList.remove('hidden'); + } + } + } catch (err) { + if (loginError) { + loginError.textContent = 'Ошибка сетевого соединения'; + loginError.classList.remove('hidden'); } - } catch (e) { - console.error('File upload error:', e); - alert('Ошибка сети или соединение прервано при загрузке файла'); } }); } - // ----------------------------------------------------------------------- - // Utility helpers - // ----------------------------------------------------------------------- - function escapeHtml(str) { - if (!str) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + localStorage.removeItem('notes_token'); + localStorage.removeItem('notes_active_book_id'); + try { await api.auth.logout(); } catch (e) {} + currentUser = null; + window.location.hash = ''; + window.location.reload(); + }); } - function escapeRegex(string) { - return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); - } - - function shuffleArray(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - } - - // Kickoff App + // 10. Check Session Auth & Kickoff checkAuth(); }); diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js new file mode 100644 index 0000000..50171e3 --- /dev/null +++ b/app/static/js/modules/api.js @@ -0,0 +1,184 @@ +// ======================================================================= +// Notes Application - API Client Module (Native ES Module) +// ======================================================================= + +// Persistent session: auto-inject Authorization header if token exists in localStorage +const originalFetch = window.fetch; +window.fetch = function(url, options = {}) { + const token = localStorage.getItem('notes_token'); + if (token && typeof url === 'string' && url.startsWith('/api/')) { + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + if (!options.headers.has('Authorization')) { + options.headers.set('Authorization', `Bearer ${token}`); + } + } else if (Array.isArray(options.headers)) { + if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) { + options.headers.push(['Authorization', `Bearer ${token}`]); + } + } else { + if (!options.headers['Authorization']) { + options.headers['Authorization'] = `Bearer ${token}`; + } + } + } + return originalFetch.call(this, url, options); +}; + +export const api = { + // Auth endpoints + auth: { + async check() { + const resp = await fetch('/api/me'); + if (resp.ok) { + return await resp.json(); + } + return null; + }, + async login(username, password) { + return await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + }, + async logout() { + return await fetch('/api/logout', { method: 'POST' }); + }, + async changePassword(currentPassword, newPassword) { + return await fetch('/api/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + old_password: currentPassword, + new_password: newPassword + }) + }); + } + }, + + // Cards & Vocabulary + cards: { + async getAll(params = {}) { + const qs = new URLSearchParams(params).toString(); + const url = qs ? `/api/cards?${qs}` : '/api/cards'; + const resp = await fetch(url); + return resp.ok ? await resp.json() : []; + }, + async lookup(term) { + const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`); + if (resp.ok) { + return await resp.json(); + } + throw new Error(`Lookup failed: ${resp.status}`); + }, + async create(payload) { + return await fetch('/api/cards', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + }, + async update(id, payload) { + return await fetch(`/api/cards/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + }, + async delete(id) { + return await fetch(`/api/cards/${id}`, { method: 'DELETE' }); + }, + async batch(payload) { + return await fetch('/api/cards/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } + }, + + // Collections & Tags + collections: { + async getAll() { + const resp = await fetch('/api/collections'); + return resp.ok ? await resp.json() : []; + } + }, + tags: { + async getAll() { + const resp = await fetch('/api/tags'); + return resp.ok ? await resp.json() : []; + } + }, + + // Statistics & Analytics + stats: { + async getStats() { + const resp = await fetch('/api/stats'); + return resp.ok ? await resp.json() : null; + }, + async getSrsStats() { + const resp = await fetch('/api/srs/stats'); + return resp.ok ? await resp.json() : null; + }, + async getHeatmap(days = 180) { + const resp = await fetch(`/api/srs/activity-heatmap?days=${days}`); + return resp.ok ? await resp.json() : { days: [] }; + } + }, + + // SRS Spaced Repetition + srs: { + async getQueue(forceAll = false, tagFilter = null) { + let url = '/api/srs/queue?limit=50'; + if (forceAll) url += '&force_all=true'; + if (tagFilter) url += `&tag=${encodeURIComponent(tagFilter)}`; + const resp = await fetch(url); + return resp.ok ? await resp.json() : []; + }, + async submitReview(cardId, rating) { + return await fetch('/api/srs/review', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ card_id: cardId, rating }) + }); + } + }, + + // Bookshelf & Reader + books: { + async getAll() { + const resp = await fetch('/api/books'); + return resp.ok ? await resp.json() : []; + }, + async get(id) { + const resp = await fetch(`/api/books/${id}`); + if (resp.ok) return await resp.json(); + throw new Error(`Failed to load book ${id}: ${resp.status}`); + }, + async uploadFile(formData) { + return await fetch('/api/books', { + method: 'POST', + body: formData + }); + }, + async createManual(payload) { + return await fetch('/api/books', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + }, + async delete(id) { + return await fetch(`/api/books/${id}`, { method: 'DELETE' }); + }, + async saveProgress(bookId, payload) { + return await fetch(`/api/books/${bookId}/progress`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + } + } +}; diff --git a/app/static/js/modules/dictionary.js b/app/static/js/modules/dictionary.js new file mode 100644 index 0000000..4932a7d --- /dev/null +++ b/app/static/js/modules/dictionary.js @@ -0,0 +1,732 @@ +// ======================================================================= +// Notes Application - Dictionary Module (Native ES Module) +// ======================================================================= + +import { api } from './api.js'; +import { escapeHtml, showToast, openModal, closeModal } from './ui.js'; + +export let allCards = []; +export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы']; +export let selectedTag = ''; +export let selectedCollection = ''; +export let searchQuery = ''; + +let currentLookupData = null; +let foundDuplicateCard = null; +let duplicateCheckTimer = null; +let isBatchMode = false; +let selectedIds = new Set(); + +export function getAllCards() { + return allCards; +} + +export function initDictionary() { + // Elements - Search & Capture + const termInput = document.getElementById('term-input'); + const clearSearchBtn = document.getElementById('clear-search-btn'); + const translateBtn = document.getElementById('translate-btn'); + const duplicateWarningBox = document.getElementById('duplicate-warning-box'); + const dupTermLabel = document.getElementById('dup-term-label'); + const dupTransLabel = document.getElementById('dup-trans-label'); + const dupOpenBtn = document.getElementById('dup-open-btn'); + + const lookupCard = document.getElementById('lookup-card'); + const resTerm = document.getElementById('res-term'); + const resPhonetic = document.getElementById('res-phonetic'); + const resInLibrary = document.getElementById('res-in-library'); + const audioPlayBtn = document.getElementById('audio-play-btn'); + const resPos = document.getElementById('res-pos'); + const resDefinitionWrap = document.getElementById('res-definition-wrap'); + const resDefinition = document.getElementById('res-definition'); + const editTranslation = document.getElementById('edit-translation'); + const editContext = document.getElementById('edit-context'); + const editCollection = document.getElementById('edit-collection'); + const collectionsDatalist = document.getElementById('collections-datalist'); + const editTags = document.getElementById('edit-tags'); + const editNotes = document.getElementById('edit-notes'); + const saveCardBtn = document.getElementById('save-card-btn'); + + // Elements - Vocabulary List + const filterInput = document.getElementById('filter-input'); + const cardsCountLabel = document.getElementById('cards-count-label'); + const collectionsFilterBar = document.getElementById('collections-filter-bar'); + const newCollectionBtn = document.getElementById('new-collection-btn'); + const tagsFilterBar = document.getElementById('tags-filter-bar'); + const cardsList = document.getElementById('cards-list'); + const emptyState = document.getElementById('empty-state'); + + // Elements - Batch Action Bar + const batchToggleBtn = document.getElementById('batch-toggle-btn'); + const batchActionBar = document.getElementById('batch-action-bar'); + const batchSelectAll = document.getElementById('batch-select-all'); + const batchCountLabel = document.getElementById('batch-count-label'); + const batchMoveBtn = document.getElementById('batch-move-btn'); + const batchTagBtn = document.getElementById('batch-tag-btn'); + const batchDeleteBtn = document.getElementById('batch-delete-btn'); + const batchCancelBtn = document.getElementById('batch-cancel-btn'); + + // Elements - Modals + const editModal = document.getElementById('edit-card-modal'); + const closeModalBtn = document.getElementById('close-modal-btn'); + const modalCancelBtn = document.getElementById('modal-cancel-btn'); + const modalSaveBtn = document.getElementById('modal-save-btn'); + const modalCardId = document.getElementById('modal-card-id'); + const modalTerm = document.getElementById('modal-term'); + const modalTranslation = document.getElementById('modal-translation'); + const modalContext = document.getElementById('modal-context'); + const modalPhonetic = document.getElementById('modal-phonetic'); + const modalPos = document.getElementById('modal-pos'); + const modalDef = document.getElementById('modal-def'); + const modalCollection = document.getElementById('modal-collection'); + const modalTags = document.getElementById('modal-tags'); + const modalNotes = document.getElementById('modal-notes'); + + const newColModal = document.getElementById('new-col-modal'); + const newColNameInput = document.getElementById('new-col-name-input'); + const closeNewColModalBtn = document.getElementById('close-new-col-modal-btn'); + const newColCancelBtn = document.getElementById('new-col-cancel-btn'); + const newColSaveBtn = document.getElementById('new-col-save-btn'); + + const moveColModal = document.getElementById('move-col-modal'); + const moveColInput = document.getElementById('move-col-input'); + const moveColQuickList = document.getElementById('move-col-quick-list'); + const closeMoveModalBtn = document.getElementById('close-move-modal-btn'); + const moveColCancelBtn = document.getElementById('move-col-cancel-btn'); + const moveColConfirmBtn = document.getElementById('move-col-confirm-btn'); + + const exportOpenBtn = document.getElementById('export-open-btn'); + const exportModal = document.getElementById('export-modal'); + const closeExportModalBtn = document.getElementById('close-export-modal-btn'); + const exportCancelBtn = document.getElementById('export-cancel-btn'); + + // ----------------------------------------------------------------------- + // Duplicate Detection & Search Term + // ----------------------------------------------------------------------- + termInput.addEventListener('input', () => { + const val = termInput.value.trim(); + clearSearchBtn.classList.toggle('hidden', val.length === 0); + + clearTimeout(duplicateCheckTimer); + if (val.length >= 2) { + duplicateCheckTimer = setTimeout(async () => { + try { + const resp = await fetch(`/api/cards/check-duplicate?term=${encodeURIComponent(val)}`); + if (resp.ok) { + const res = await resp.json(); + if (res.exists && res.card) { + foundDuplicateCard = res.card; + dupTermLabel.textContent = res.card.term; + dupTransLabel.textContent = res.card.translation; + duplicateWarningBox.classList.remove('hidden'); + } else { + foundDuplicateCard = null; + duplicateWarningBox.classList.add('hidden'); + } + } + } catch (e) {} + }, 300); + } else { + foundDuplicateCard = null; + duplicateWarningBox.classList.add('hidden'); + } + }); + + dupOpenBtn.addEventListener('click', () => { + if (foundDuplicateCard) { + openEditModal(foundDuplicateCard); + } + }); + + clearSearchBtn.addEventListener('click', () => { + termInput.value = ''; + clearSearchBtn.classList.add('hidden'); + duplicateWarningBox.classList.add('hidden'); + lookupCard.classList.add('hidden'); + termInput.focus(); + }); + + // ----------------------------------------------------------------------- + // Dictionary Lookup & Capture + // ----------------------------------------------------------------------- + translateBtn.addEventListener('click', performLookup); + termInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + performLookup(); + } else if (e.key === 'Escape') { + termInput.value = ''; + clearSearchBtn.classList.add('hidden'); + duplicateWarningBox.classList.add('hidden'); + lookupCard.classList.add('hidden'); + } + }); + + async function performLookup() { + const term = termInput.value.trim(); + if (!term) return; + + translateBtn.disabled = true; + translateBtn.textContent = 'Ищем...'; + + try { + const data = await api.cards.lookup(term); + currentLookupData = data; + displayLookupResult(data); + } catch (e) { + console.error('Lookup error:', e); + showToast('Не удалось получить перевод', 'error'); + } finally { + translateBtn.disabled = false; + translateBtn.textContent = 'Перевести'; + } + } + + function displayLookupResult(data) { + resTerm.textContent = data.term; + resPhonetic.textContent = data.phonetic || ''; + resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none'; + resPos.textContent = data.part_of_speech || 'Phrase'; + audioPlayBtn.classList.add('hidden'); + + if (data.definition_en) { + resDefinition.textContent = data.definition_en; + resDefinitionWrap.classList.remove('hidden'); + } else { + resDefinitionWrap.classList.add('hidden'); + } + + const inLib = Boolean(data.in_library || foundDuplicateCard); + if (resInLibrary) { + resInLibrary.classList.toggle('hidden', !inLib); + } + + if (inLib) { + saveCardBtn.innerHTML = '⭐ Обновить в словаре (Ctrl + Enter)'; + const lib = data.library_card || foundDuplicateCard; + if (lib) { + if (lib.translation) editTranslation.value = lib.translation; + if (lib.context_sentence) editContext.value = lib.context_sentence; + if (lib.collection) editCollection.value = lib.collection; + if (lib.tags) editTags.value = lib.tags; + if (lib.notes) editNotes.value = lib.notes; + } else { + editTranslation.value = data.translation || ''; + } + } else { + saveCardBtn.innerHTML = '💾 Сохранить в словарь (Ctrl + Enter)'; + editTranslation.value = data.translation || ''; + editContext.value = ''; + editNotes.value = ''; + editCollection.value = selectedCollection || 'General'; + } + + lookupCard.classList.remove('hidden'); + editTranslation.focus(); + } + + // Save Card Action + saveCardBtn.addEventListener('click', saveNewCard); + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + if (!lookupCard.classList.contains('hidden')) { + e.preventDefault(); + saveNewCard(); + } + } + }); + + async function saveNewCard() { + if (!currentLookupData) return; + const term = currentLookupData.term.trim(); + const translation = editTranslation.value.trim(); + if (!translation) { + alert('Пожалуйста, укажите русский перевод'); + editTranslation.focus(); + return; + } + + const payload = { + term: term, + translation: translation, + context_sentence: editContext.value.trim(), + phonetic: currentLookupData.phonetic || '', + audio_url: currentLookupData.audio_url || '', + part_of_speech: currentLookupData.part_of_speech || '', + definition_en: currentLookupData.definition_en || '', + tags: editTags.value.trim() || 'general', + notes: editNotes.value.trim(), + collection: editCollection.value.trim() || 'General', + }; + + saveCardBtn.disabled = true; + saveCardBtn.textContent = 'Сохраняем...'; + + try { + const resp = await api.cards.create(payload); + if (resp.ok) { + termInput.value = ''; + clearSearchBtn.classList.add('hidden'); + duplicateWarningBox.classList.add('hidden'); + lookupCard.classList.add('hidden'); + currentLookupData = null; + + showToast(`Слово "${term}" сохранено в словарь!`, 'success'); + await loadInitialData(); + termInput.focus(); + } else { + const err = await resp.json(); + alert('Ошибка сохранения: ' + (err.detail || 'Не удалось сохранить')); + } + } catch (e) { + alert('Сетевая ошибка при сохранении'); + } finally { + saveCardBtn.disabled = false; + saveCardBtn.innerHTML = '💾 Сохранить в словарь (Ctrl + Enter)'; + } + } + + // ----------------------------------------------------------------------- + // Cards List & Filtering + // ----------------------------------------------------------------------- + filterInput.addEventListener('input', () => { + searchQuery = filterInput.value.trim(); + fetchCards(); + }); + + function renderCollectionsFilter(cols) { + collectionsFilterBar.innerHTML = ''; + const allBtn = document.createElement('button'); + allBtn.className = `col-chip ${selectedCollection === '' ? 'active' : ''}`; + allBtn.textContent = 'Все коллекции'; + allBtn.onclick = () => { + selectedCollection = ''; + updateFilterChips(); + fetchCards(); + }; + collectionsFilterBar.appendChild(allBtn); + + cols.forEach(c => { + const chip = document.createElement('button'); + chip.className = `col-chip ${selectedCollection === c.name ? 'active' : ''}`; + chip.textContent = `${c.name} (${c.count})`; + chip.onclick = () => { + selectedCollection = c.name; + updateFilterChips(); + fetchCards(); + }; + collectionsFilterBar.appendChild(chip); + }); + } + + function renderTagsFilter(tags) { + tagsFilterBar.innerHTML = ''; + const allBtn = document.createElement('button'); + allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`; + allBtn.textContent = 'Все теги'; + allBtn.onclick = () => { + selectedTag = ''; + updateFilterChips(); + fetchCards(); + }; + tagsFilterBar.appendChild(allBtn); + + tags.slice(0, 15).forEach(t => { + const chip = document.createElement('button'); + chip.className = `filter-chip ${selectedTag === t.tag ? 'active' : ''}`; + chip.textContent = `#${t.tag} (${t.count})`; + chip.onclick = () => { + selectedTag = selectedTag === t.tag ? '' : t.tag; + updateFilterChips(); + fetchCards(); + }; + tagsFilterBar.appendChild(chip); + }); + } + + function updateFilterChips() { + document.querySelectorAll('.col-chip').forEach(el => { + el.classList.toggle('active', el.textContent.startsWith(selectedCollection || 'Все коллекции')); + }); + document.querySelectorAll('.filter-chip').forEach(el => { + el.classList.toggle('active', el.textContent.startsWith(`#${selectedTag}`) || (!selectedTag && el.textContent.startsWith('Все теги'))); + }); + } + + function renderCardsList() { + cardsCountLabel.textContent = `(${allCards.length} записей)`; + cardsList.innerHTML = ''; + + if (allCards.length === 0) { + emptyState.classList.remove('hidden'); + cardsList.appendChild(emptyState); + return; + } + + emptyState.classList.add('hidden'); + + allCards.forEach(c => { + const cardEl = document.createElement('div'); + cardEl.className = `vocab-card ${isBatchMode && selectedIds.has(c.id) ? 'is-selected' : ''}`; + + const srsStateClass = c.srs_state === 'mastered' ? 'dot-mastered' : (c.srs_state === 'review' ? 'dot-review' : (c.srs_state === 'learning' ? 'dot-learning' : 'dot-new')); + + cardEl.innerHTML = ` +
+
+ ${isBatchMode ? `` : ''} + + ${escapeHtml(c.term)} + ${c.phonetic ? `${escapeHtml(c.phonetic)}` : ''} + ${c.part_of_speech ? `${escapeHtml(c.part_of_speech)}` : ''} +
+
+ + +
+
+
${escapeHtml(c.translation)}
+ ${c.context_sentence ? `
“${escapeHtml(c.context_sentence)}”
` : ''} +
+
+ 📁 ${escapeHtml(c.collection || 'General')} + ${c.tags ? c.tags.split(',').map(t => `#${escapeHtml(t.trim())}`).join(' ') : ''} +
+ ${c.notes ? `
💡 ${escapeHtml(c.notes)}
` : ''} +
+ `; + + // Edit button + cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => { + e.stopPropagation(); + openEditModal(c); + }); + + // Delete button + cardEl.querySelector('.delete-card-btn').addEventListener('click', async (e) => { + e.stopPropagation(); + if (confirm(`Удалить слово "${c.term}" из словаря?`)) { + await api.cards.delete(c.id); + showToast(`Слово "${c.term}" удалено`, 'info'); + await loadInitialData(); + } + }); + + // Batch selection click + if (isBatchMode) { + cardEl.addEventListener('click', () => { + toggleBatchSelection(c.id); + }); + } + + cardsList.appendChild(cardEl); + }); + } + + // ----------------------------------------------------------------------- + // Batch Operations + // ----------------------------------------------------------------------- + batchToggleBtn.addEventListener('click', () => { + isBatchMode = !isBatchMode; + batchToggleBtn.textContent = isBatchMode ? '✓ Завершить выбор' : '☑️ Выбрать'; + batchToggleBtn.classList.toggle('btn-primary', isBatchMode); + batchToggleBtn.classList.toggle('btn-outline', !isBatchMode); + batchActionBar.classList.toggle('hidden', !isBatchMode); + if (!isBatchMode) { + selectedIds.clear(); + } + renderCardsList(); + updateBatchCount(); + }); + + batchCancelBtn.addEventListener('click', () => { + isBatchMode = false; + batchToggleBtn.textContent = '☑️ Выбрать'; + batchToggleBtn.classList.remove('btn-primary'); + batchToggleBtn.classList.add('btn-outline'); + batchActionBar.classList.add('hidden'); + selectedIds.clear(); + renderCardsList(); + }); + + function toggleBatchSelection(id) { + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + updateBatchCount(); + renderCardsList(); + } + + batchSelectAll.addEventListener('change', () => { + if (batchSelectAll.checked) { + allCards.forEach(c => selectedIds.add(c.id)); + } else { + selectedIds.clear(); + } + updateBatchCount(); + renderCardsList(); + }); + + function updateBatchCount() { + batchCountLabel.textContent = `Выбрано: ${selectedIds.size}`; + batchSelectAll.checked = selectedIds.size > 0 && selectedIds.size === allCards.length; + } + + batchDeleteBtn.addEventListener('click', async () => { + if (selectedIds.size === 0) return; + if (confirm(`Удалить выбранные карточки (${selectedIds.size} шт.)?`)) { + await api.cards.batch({ + card_ids: Array.from(selectedIds), + action: 'delete' + }); + showToast(`Удалено карточек: ${selectedIds.size}`, 'info'); + selectedIds.clear(); + await loadInitialData(); + } + }); + + batchMoveBtn.addEventListener('click', () => { + if (selectedIds.size === 0) { + alert('Сначала выберите хотя бы одну карточку'); + return; + } + openModal(moveColModal); + }); + + batchTagBtn.addEventListener('click', async () => { + if (selectedIds.size === 0) return; + const tagVal = prompt('Введите тег(и) для добавления (через запятую):'); + if (tagVal && tagVal.trim()) { + await api.cards.batch({ + card_ids: Array.from(selectedIds), + action: 'add_tags', + value: tagVal.trim() + }); + showToast(`Теги добавлены`, 'success'); + await loadInitialData(); + } + }); + + // ----------------------------------------------------------------------- + // Edit Card Modal + // ----------------------------------------------------------------------- + function openEditModal(card) { + modalCardId.value = card.id; + modalTerm.value = card.term; + modalTranslation.value = card.translation; + modalContext.value = card.context_sentence || ''; + modalPhonetic.value = card.phonetic || ''; + modalPos.value = card.part_of_speech || ''; + modalDef.value = card.definition_en || ''; + modalCollection.value = card.collection || 'General'; + modalTags.value = card.tags || ''; + modalNotes.value = card.notes || ''; + openModal(editModal); + } + + closeModalBtn.addEventListener('click', () => closeModal(editModal)); + modalCancelBtn.addEventListener('click', () => closeModal(editModal)); + + modalSaveBtn.addEventListener('click', async () => { + const id = modalCardId.value; + const payload = { + term: modalTerm.value.trim(), + translation: modalTranslation.value.trim(), + context_sentence: modalContext.value.trim(), + phonetic: modalPhonetic.value.trim(), + part_of_speech: modalPos.value.trim(), + definition_en: modalDef.value.trim(), + collection: modalCollection.value.trim() || 'General', + tags: modalTags.value.trim(), + notes: modalNotes.value.trim(), + }; + + const resp = await api.cards.update(id, payload); + if (resp.ok) { + closeModal(editModal); + showToast('Карточка успешно обновлена', 'success'); + await loadInitialData(); + } else { + alert('Ошибка при сохранении карточки'); + } + }); + + // ----------------------------------------------------------------------- + // Export Modal + // ----------------------------------------------------------------------- + if (exportOpenBtn) { + exportOpenBtn.addEventListener('click', () => openModal(exportModal)); + } + closeExportModalBtn.addEventListener('click', () => closeModal(exportModal)); + exportCancelBtn.addEventListener('click', () => closeModal(exportModal)); + + document.querySelectorAll('.export-tile').forEach(tile => { + tile.addEventListener('click', () => { + const format = tile.dataset.format; + let url = `/api/export/${format}`; + if (selectedCollection) url += `?collection=${encodeURIComponent(selectedCollection)}`; + window.location.href = url; + }); + }); + + // ----------------------------------------------------------------------- + // Collections Management + // ----------------------------------------------------------------------- + newCollectionBtn.addEventListener('click', () => { + openModal(newColModal); + newColNameInput.value = ''; + newColNameInput.focus(); + }); + + closeNewColModalBtn.addEventListener('click', () => closeModal(newColModal)); + newColCancelBtn.addEventListener('click', () => closeModal(newColModal)); + + newColSaveBtn.addEventListener('click', () => { + const name = newColNameInput.value.trim(); + if (name) { + selectedCollection = name; + closeModal(newColModal); + fetchCards(); + } + }); + + closeMoveModalBtn.addEventListener('click', () => closeModal(moveColModal)); + moveColCancelBtn.addEventListener('click', () => closeModal(moveColModal)); + + moveColConfirmBtn.addEventListener('click', async () => { + const col = moveColInput.value.trim(); + if (col && selectedIds.size > 0) { + await api.cards.batch({ + card_ids: Array.from(selectedIds), + action: 'set_collection', + value: col, + }); + closeModal(moveColModal); + selectedIds.clear(); + showToast(`Перемещено в "${col}"`, 'success'); + await loadInitialData(); + } + }); + + function populateDatalists() { + collectionsDatalist.innerHTML = ''; + const dl = document.getElementById('move-collections-datalist'); + if (dl) dl.innerHTML = ''; + + collectionsList.forEach(c => { + const opt = document.createElement('option'); + opt.value = c; + collectionsDatalist.appendChild(opt); + if (dl) dl.appendChild(opt.cloneNode(true)); + }); + + if (moveColQuickList) { + moveColQuickList.innerHTML = ''; + collectionsList.forEach(c => { + const btn = document.createElement('button'); + btn.className = 'btn btn-xs btn-outline'; + btn.textContent = c; + btn.onclick = () => { moveColInput.value = c; }; + moveColQuickList.appendChild(btn); + }); + } + } + + // ----------------------------------------------------------------------- + // Data Loading Functions + // ----------------------------------------------------------------------- + async function fetchCards() { + try { + const params = { limit: 500 }; + if (selectedCollection) params.collection = selectedCollection; + if (selectedTag) params.tag = selectedTag; + if (searchQuery) params.search = searchQuery; + + const data = await api.cards.getAll(params); + allCards = data.cards || []; + renderCardsList(); + } catch (e) { + console.error('Error fetching cards:', e); + } + } + + async function fetchCollections() { + try { + const cols = await api.collections.getAll(); + collectionsList = cols.map(c => c.name); + renderCollectionsFilter(cols); + populateDatalists(); + } catch (e) { + console.error('Error fetching collections:', e); + } + } + + async function fetchTags() { + try { + const tags = await api.tags.getAll(); + renderTagsFilter(tags); + } catch (e) { + console.error('Error fetching tags:', e); + } + } + + async function fetchStats() { + try { + const data = await api.stats.getStats(); + if (data) { + const statTotal = document.getElementById('stat-total'); + const statToday = document.getElementById('stat-today'); + if (statTotal) statTotal.textContent = data.total_cards; + if (statToday) statToday.textContent = `+${data.today_added}`; + } + } catch (e) {} + } + + async function fetchSrsStats() { + try { + const stats = await api.stats.getSrsStats(); + if (stats) { + const due = stats.due_cards || 0; + const srsBadge = document.getElementById('srs-badge'); + if (srsBadge) srsBadge.textContent = due; + const tabSrsBadge = document.getElementById('tab-srs-badge'); + if (tabSrsBadge) { + tabSrsBadge.textContent = due; + tabSrsBadge.classList.toggle('hidden', due === 0); + } + } + } catch (e) {} + } + + async function loadInitialData() { + await Promise.all([ + fetchCards(), + fetchCollections(), + fetchTags(), + fetchStats(), + fetchSrsStats(), + ]); + } + + // Attach to module exports + initDictionary.fetchCards = fetchCards; + initDictionary.loadInitialData = loadInitialData; + initDictionary.openEditModal = openEditModal; +} + +export async function loadInitialData() { + if (initDictionary.loadInitialData) { + return await initDictionary.loadInitialData(); + } +} + +export async function fetchCards() { + if (initDictionary.fetchCards) { + return await initDictionary.fetchCards(); + } +} + +export function openEditModal(card) { + if (initDictionary.openEditModal) { + initDictionary.openEditModal(card); + } +} diff --git a/app/static/js/modules/reader.js b/app/static/js/modules/reader.js new file mode 100644 index 0000000..651d850 --- /dev/null +++ b/app/static/js/modules/reader.js @@ -0,0 +1,2045 @@ +// ======================================================================= +// Notes Application - Reader & Bookshelf Module (Native ES Module) +// ======================================================================= + +import { api } from './api.js'; +import { escapeHtml, escapeRegex, slugify, showToast } from './ui.js'; +import { getAllCards, fetchCards } from './dictionary.js'; + +// Reader State & Appearance Settings +const DEFAULT_READER_SETTINGS = { + fontSize: 18, + fontFamily: 'serif', + lineHeight: 1.65, + marginWidth: 'normal', + theme: 'dark', + textAlign: 'left', + flowMode: 'chapters', // 'chapters' | 'continuous' + autoLoadNext: true // boolean: seamlessly load next chapter on scroll +}; + +let readerSettings = { ...DEFAULT_READER_SETTINGS }; +let currentBook = null; +let currentChapterIndex = 0; +const loadedChaptersSet = new Set(); +let isAppendingChapter = false; +let isHighlightEnabled = false; +let popupCurrentData = null; +let readerScrollSaveTimer = null; +let popupScrollRaf = null; + +export function getCurrentBook() { + return currentBook; +} + +export function getCurrentChapterIndex() { + return currentChapterIndex; +} + +// ----------------------------------------------------------------------- +// Bookshelf Actions & Rendering +// ----------------------------------------------------------------------- +export async function loadBookshelf() { + const bookshelfContainer = document.getElementById('bookshelf-container'); + const readerInterface = document.getElementById('reader-interface'); + const bookshelfGrid = document.getElementById('bookshelf-grid'); + const bookshelfEmpty = document.getElementById('bookshelf-empty'); + + if (!bookshelfContainer) return; + if (!window.location.hash.includes('/book/')) { + bookshelfContainer.classList.remove('hidden'); + if (readerInterface) readerInterface.classList.add('hidden'); + } + if (bookshelfGrid) bookshelfGrid.innerHTML = ''; + if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden'); + + try { + const books = await api.books.getAll(); + if (!books || books.length === 0) { + if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden'); + } else { + renderBookshelf(books); + } + } catch (e) { + console.error('Error loading bookshelf:', e); + } +} + +export function renderBookshelf(books) { + const bookshelfGrid = document.getElementById('bookshelf-grid'); + if (!bookshelfGrid) return; + bookshelfGrid.innerHTML = ''; + + books.forEach(b => { + const card = document.createElement('div'); + card.className = 'book-card'; + + const totalCh = Math.max(1, b.chapters_count || 1); + const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0)); + const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0; + + const progressFraction = (chIndex + pos) / totalCh; + const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100))); + const chPct = Math.min(100, Math.max(0, Math.round(pos * 100))); + + const progressLabels = totalCh === 1 + ? `1 глава • ${totalPct}%${totalPct}%` + : `Глава ${chIndex + 1} из ${totalCh} (${chPct}%)${totalPct}%`; + + card.innerHTML = ` +
+
+
${b.source_type === 'epub' ? '📕' : (b.source_type === 'pdf' ? '📑' : '📄')}
+
+

${escapeHtml(b.title)}

+

${escapeHtml(b.author || 'Неизвестный автор')}

+
+ ${escapeHtml((b.source_type || 'text').toUpperCase())} + ${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''} +
+
+
+
+
+ ${progressLabels} +
+
+
+
+
+
+
+ + + +
+ `; + + card.querySelector('.read-book-btn').addEventListener('click', () => { + window.location.hash = `#reader/book/${b.id}`; + }); + card.querySelector('.train-book-btn').addEventListener('click', () => { + const bookTag = slugify(b.title); + window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; + }); + card.querySelector('.delete-book-btn').addEventListener('click', async () => { + if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) { + await api.books.delete(b.id); + loadBookshelf(); + } + }); + + bookshelfGrid.appendChild(card); + }); +} + +// ----------------------------------------------------------------------- +// Open Book & Render Chapters +// ----------------------------------------------------------------------- +export async function openBook(bookId, specificChapterIdx = null, updateHash = true) { + if (!bookId) return; + + const readerView = document.getElementById('reader-view'); + const dictionaryView = document.getElementById('dictionary-view'); + const srsView = document.getElementById('srs-view'); + const bookshelfContainer = document.getElementById('bookshelf-container'); + const readerInterface = document.getElementById('reader-interface'); + const readerBookTitle = document.getElementById('reader-book-title'); + const readerBookAuthor = document.getElementById('reader-book-author'); + const readerChapterSelect = document.getElementById('reader-chapter-select'); + const readerContentBody = document.getElementById('reader-content-body'); + + if (readerView) readerView.classList.remove('hidden'); + if (dictionaryView) dictionaryView.classList.add('hidden'); + if (srsView) srsView.classList.add('hidden'); + if (bookshelfContainer) bookshelfContainer.classList.add('hidden'); + if (readerInterface) readerInterface.classList.remove('hidden'); + + // Update mobile navigation tabs + const navItems = document.querySelectorAll('.mobile-tab-btn'); + navItems.forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === 'reader'); + }); + + if (currentBook && currentBook.id === bookId) { + if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { + if (specificChapterIdx !== currentChapterIndex) { + if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(specificChapterIdx)) { + currentChapterIndex = specificChapterIdx; + if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; + const target = document.getElementById(`chapter-anchor-${specificChapterIdx}`) + || (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${specificChapterIdx}"]`)); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } else { + renderChapter(specificChapterIdx, 0, 0, 0); + } + } + } + if (updateHash) { + const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; + if (window.location.hash !== desiredHash) { + history.replaceState(null, '', desiredHash); + } + } + return; + } + + try { + if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...'; + const bookData = await api.books.get(bookId); + currentBook = bookData; + + // Load saved reading position from localStorage or server + let targetChapter = currentBook.current_chapter || 0; + let targetScrollTop = null; + let targetFraction = currentBook.current_position || 0; + let targetAnchorIdx = null; + + 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; + targetAnchorIdx = parsed.anchorIdx; + } + } + } catch (e) {} + + if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) { + targetChapter = specificChapterIdx; + if (specificChapterIdx !== (currentBook.current_chapter || 0)) { + targetScrollTop = 0; + targetFraction = 0; + targetAnchorIdx = 0; + } + } + + currentChapterIndex = Math.max(0, Math.min(targetChapter, (currentBook.chapters ? currentBook.chapters.length - 1 : 0))); + + if (readerBookTitle) readerBookTitle.textContent = currentBook.title; + if (readerBookAuthor) readerBookAuthor.textContent = currentBook.author || ''; + + // Populate chapter selector with visual status markers + if (readerChapterSelect) { + readerChapterSelect.innerHTML = ''; + const chapters = currentBook.chapters || []; + chapters.forEach((ch, idx) => { + const opt = document.createElement('option'); + opt.value = idx; + const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ '); + opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`; + readerChapterSelect.appendChild(opt); + }); + } + + applyReaderSettings(); + + try { + localStorage.setItem('notes_active_book_id', bookId); + localStorage.setItem('notes_active_view', 'reader-view'); + } catch (e) {} + + if (updateHash) { + const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`; + if (window.location.hash !== desiredHash) { + history.replaceState(null, '', desiredHash); + } + } + + renderChapter(currentChapterIndex, targetScrollTop, targetFraction, targetAnchorIdx); + } catch (e) { + console.error('Error opening book:', e); + alert('Не удалось открыть книгу'); + window.location.hash = '#reader'; + } +} + +export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false) { + if (!currentBook || !currentBook.chapters) return document.createDocumentFragment(); + const chapter = currentBook.chapters[chapterIdx] || { title: '', content: '' }; + const frag = document.createDocumentFragment(); + + if (chapter.title || isContinuous) { + if (isContinuous || chapterIdx > 0) { + const divider = document.createElement('div'); + divider.className = 'reader-chapter-divider'; + divider.id = `chapter-anchor-${chapterIdx}`; + divider.setAttribute('data-chapter-idx', chapterIdx.toString()); + + const badge = document.createElement('span'); + badge.className = 'divider-badge'; + badge.textContent = `Глава ${chapterIdx + 1} из ${currentBook.chapters.length}`; + + const titleEl = document.createElement('h2'); + titleEl.className = 'divider-title'; + titleEl.textContent = chapter.title || `Глава ${chapterIdx + 1}`; + + divider.appendChild(badge); + divider.appendChild(titleEl); + frag.appendChild(divider); + } else if (chapter.title) { + const h = document.createElement('h2'); + h.textContent = chapter.title; + h.setAttribute('data-block-idx', '0'); + h.setAttribute('data-chapter-idx', chapterIdx.toString()); + h.id = `chapter-anchor-${chapterIdx}`; + frag.appendChild(h); + } + } + + const blocks = parseContentBlocks(chapter.content); + + blocks.forEach((block, blockIdx) => { + const domIdx = blockIdx + 1; + let blockEl = null; + + if (block.type === 'code') { + blockEl = document.createElement('div'); + blockEl.className = 'reader-code-block'; + + const header = document.createElement('div'); + header.className = 'reader-code-header'; + + const langSpan = document.createElement('span'); + langSpan.className = 'reader-code-lang'; + langSpan.textContent = block.lang || 'code'; + + const copyBtn = document.createElement('button'); + copyBtn.className = 'reader-copy-code-btn'; + copyBtn.type = 'button'; + copyBtn.innerHTML = '📋 Копировать'; + copyBtn.onclick = async (e) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(block.code); + copyBtn.innerHTML = '✅ Скопировано!'; + setTimeout(() => { copyBtn.innerHTML = '📋 Копировать'; }, 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }; + + header.appendChild(langSpan); + header.appendChild(copyBtn); + + const pre = document.createElement('pre'); + const codeEl = document.createElement('code'); + if (block.lang) codeEl.className = `language-${block.lang}`; + codeEl.textContent = block.code; + pre.appendChild(codeEl); + + blockEl.appendChild(header); + blockEl.appendChild(pre); + } else if (block.type === 'table') { + blockEl = document.createElement('div'); + blockEl.className = 'reader-table-wrap'; + const table = document.createElement('table'); + table.className = 'reader-table'; + + if (block.headers && block.headers.length > 0) { + const thead = document.createElement('thead'); + const headerTr = document.createElement('tr'); + block.headers.forEach((hText, colIdx) => { + const th = document.createElement('th'); + if (block.alignments && block.alignments[colIdx]) { + th.style.textAlign = block.alignments[colIdx]; + } + renderInteractiveSpans(th, hText, hText, knownWordsMap); + headerTr.appendChild(th); + }); + thead.appendChild(headerTr); + table.appendChild(thead); + } + + if (block.rows && block.rows.length > 0) { + const tbody = document.createElement('tbody'); + block.rows.forEach(row => { + const tr = document.createElement('tr'); + row.forEach((cellText, colIdx) => { + const td = document.createElement('td'); + if (block.alignments && block.alignments[colIdx]) { + td.style.textAlign = block.alignments[colIdx]; + } + renderInteractiveSpans(td, cellText, cellText, knownWordsMap); + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + } + blockEl.appendChild(table); + } else if (block.type === 'heading') { + const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`; + blockEl = document.createElement(tag); + if (block.align) blockEl.style.textAlign = block.align; + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); + } else if (block.type === 'blockquote') { + blockEl = document.createElement('blockquote'); + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); + } else if (block.type === 'ul' || block.type === 'ol') { + blockEl = document.createElement(block.type); + block.items.forEach(itemText => { + const li = document.createElement('li'); + renderInteractiveSpans(li, itemText, itemText, knownWordsMap); + blockEl.appendChild(li); + }); + } else if (block.type === 'hr') { + blockEl = document.createElement('hr'); + } else { + blockEl = document.createElement('p'); + if (block.align) { + blockEl.style.textAlign = block.align; + blockEl.classList.add('text-center'); + } + renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap); + } + + if (blockEl) { + blockEl.setAttribute('data-block-idx', domIdx.toString()); + blockEl.setAttribute('data-chapter-idx', chapterIdx.toString()); + frag.appendChild(blockEl); + } + }); + + return frag; +} + +export function buildChapterFooterDOM(chapterIdx) { + if (!currentBook || !currentBook.chapters) return null; + const totalCh = currentBook.chapters.length; + const isLastChapter = (chapterIdx >= totalCh - 1); + + const footer = document.createElement('div'); + footer.className = 'reader-chapter-footer'; + footer.id = `reader-chapter-footer-${chapterIdx}`; + footer.setAttribute('data-footer-for-ch', chapterIdx.toString()); + + if (isLastChapter) { + footer.classList.add('reader-book-finished'); + footer.innerHTML = ` + + + + `; + const trainBtn = footer.querySelector(`#footer-train-btn-${chapterIdx}`); + if (trainBtn) { + trainBtn.onclick = () => { + const tag = slugify(currentBook.title); + window.location.hash = `#srs/${encodeURIComponent(tag)}`; + }; + } + const shelfBtn = footer.querySelector(`#footer-shelf-btn-${chapterIdx}`); + if (shelfBtn) { + shelfBtn.onclick = () => { + window.location.hash = '#reader'; + }; + } + return footer; + } + + const nextCh = chapterIdx + 1; + const nextTitle = currentBook.chapters[nextCh]?.title || `Глава ${nextCh + 1}`; + + footer.innerHTML = ` + + + + `; + + const nextBtn = footer.querySelector(`#footer-next-btn-${chapterIdx}`); + if (nextBtn) { + nextBtn.onclick = () => { + if (readerSettings.flowMode === 'continuous') { + const target = document.getElementById(`chapter-anchor-${nextCh}`); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else if (readerSettings.autoLoadNext) { + appendNextChapter(nextCh, true); + } else { + window.location.hash = `#reader/book/${currentBook.id}/ch/${nextCh}`; + } + }; + } + + const prevBtn = footer.querySelector(`#footer-prev-btn-${chapterIdx}`); + if (prevBtn) { + prevBtn.onclick = () => { + window.location.hash = `#reader/book/${currentBook.id}/ch/${chapterIdx - 1}`; + }; + } + + return footer; +} + +export function appendNextChapter(nextCh, smoothScroll = false) { + if (!currentBook || !currentBook.chapters || nextCh >= currentBook.chapters.length) return; + const readerContentBody = document.getElementById('reader-content-body'); + const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); + const readerNextChBtn = document.getElementById('reader-next-ch-btn'); + + if (!readerContentBody) return; + + if (loadedChaptersSet.has(nextCh)) { + if (smoothScroll) { + const target = document.getElementById(`chapter-anchor-${nextCh}`); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return; + } + + const oldFooter = readerContentBody.querySelector(`.reader-chapter-footer[data-footer-for-ch="${nextCh - 1}"]`); + if (oldFooter) { + oldFooter.remove(); + } + + const knownWordsMap = new Map(); + const allCards = getAllCards() || []; + allCards.forEach(c => { + if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); + }); + + const chFrag = buildChapterDOM(nextCh, knownWordsMap, true); + readerContentBody.appendChild(chFrag); + + const newFooter = buildChapterFooterDOM(nextCh); + if (newFooter) { + readerContentBody.appendChild(newFooter); + } + + loadedChaptersSet.add(nextCh); + + if (readerPrevChBtn) readerPrevChBtn.disabled = false; + if (readerNextChBtn) readerNextChBtn.disabled = nextCh >= currentBook.chapters.length - 1; + + if (smoothScroll) { + const anchor = document.getElementById(`chapter-anchor-${nextCh}`); + if (anchor) { + anchor.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } +} + +export function renderContinuousBook(targetChapter = 0, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) { + if (!currentBook || !currentBook.chapters) return; + + const readerChapterSelect = document.getElementById('reader-chapter-select'); + const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); + const readerNextChBtn = document.getElementById('reader-next-ch-btn'); + const readerContentBody = document.getElementById('reader-content-body'); + + currentChapterIndex = Math.max(0, Math.min(targetChapter, currentBook.chapters.length - 1)); + if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; + + if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0; + if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; + + if (!readerContentBody) return; + readerContentBody.innerHTML = ''; + loadedChaptersSet.clear(); + + const knownWordsMap = new Map(); + const allCards = getAllCards() || []; + allCards.forEach(c => { + if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); + }); + + loadedChaptersSet.add(currentChapterIndex); + const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false); + readerContentBody.appendChild(chFrag); + + const footer = buildChapterFooterDOM(currentChapterIndex); + if (footer) { + readerContentBody.appendChild(footer); + } + + applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); + + const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0; + api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: posToSave }) + .catch(err => console.log('Progress save error:', err)); +} + +export function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) { + if (!currentBook || !currentBook.chapters) return; + + if (readerSettings.flowMode === 'continuous') { + renderContinuousBook(index, restoreScrollTop, restoreFraction, restoreAnchorIdx); + return; + } + + const readerChapterSelect = document.getElementById('reader-chapter-select'); + const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); + const readerNextChBtn = document.getElementById('reader-next-ch-btn'); + const readerContentBody = document.getElementById('reader-content-body'); + + currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1)); + if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; + + // Refresh status icons in chapter select + if (readerChapterSelect && readerChapterSelect.options) { + for (let i = 0; i < readerChapterSelect.options.length; i++) { + const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`; + const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ '); + readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`; + } + } + + if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0; + if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; + + if (!readerContentBody) return; + readerContentBody.innerHTML = ''; + loadedChaptersSet.clear(); + loadedChaptersSet.add(currentChapterIndex); + + const knownWordsMap = new Map(); + const allCards = getAllCards() || []; + allCards.forEach(c => { + if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); + }); + + const chFrag = buildChapterDOM(currentChapterIndex, knownWordsMap, false); + readerContentBody.appendChild(chFrag); + + const footer = buildChapterFooterDOM(currentChapterIndex); + if (footer) { + readerContentBody.appendChild(footer); + } + + applyScroll(restoreAnchorIdx, restoreScrollTop, restoreFraction, currentChapterIndex); + + // Save progress to server + const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0; + api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: posToSave }) + .catch(err => console.log('Progress save error:', err)); +} + +export function applyScroll(restoreAnchorIdx = null, restoreScrollTop = null, restoreFraction = null, chIdx = 0) { + const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerContentBody = document.getElementById('reader-content-body'); + if (!readerScrollArea) return; + + function doScroll() { + if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) { + const el = (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${chIdx}"][data-block-idx="${restoreAnchorIdx}"]`)) + || (readerContentBody && readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`)); + if (el) { + const containerTop = readerScrollArea.getBoundingClientRect().top; + const elTop = el.getBoundingClientRect().top; + readerScrollArea.scrollTop += (elTop - containerTop - 10); + updateReaderProgressBar(); + 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 if (chIdx > 0) { + const anchor = document.getElementById(`chapter-anchor-${chIdx}`); + if (anchor) { + const containerTop = readerScrollArea.getBoundingClientRect().top; + const elTop = anchor.getBoundingClientRect().top; + readerScrollArea.scrollTop += (elTop - containerTop - 10); + } else { + readerScrollArea.scrollTop = 0; + } + } else { + readerScrollArea.scrollTop = 0; + } + updateReaderProgressBar(); + } + + requestAnimationFrame(() => { + doScroll(); + setTimeout(doScroll, 100); + }); +} + +// ----------------------------------------------------------------------- +// Content Block Parsing & Interactive Spans +// ----------------------------------------------------------------------- +export function parseContentBlocks(content) { + if (!content) return []; + const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + const blocks = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + // 1. Fenced Code Block: ```lang ... ``` + if (trimmed.startsWith('```')) { + const lang = trimmed.slice(3).trim() || 'code'; + const codeLines = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i++; + } + if (i < lines.length && lines[i].trim().startsWith('```')) { + i++; // skip closing ``` + } + blocks.push({ + type: 'code', + lang: lang, + code: codeLines.join('\n') + }); + continue; + } + + // 2. Empty line + if (!trimmed) { + i++; + continue; + } + + // 3. Markdown Table: lines starting with | and ending with | + if (trimmed.startsWith('|') && trimmed.endsWith('|')) { + const tableLines = []; + while (i < lines.length && lines[i].trim().startsWith('|')) { + tableLines.push(lines[i].trim()); + i++; + } + if (tableLines.length >= 2) { + const splitRow = (rowStr) => { + const inner = rowStr.replace(/^\|/, '').replace(/\|$/, ''); + return inner.split(/(? c.trim().replace(/\\\|/g, '|')); + }; + const headers = splitRow(tableLines[0]); + const alignRow = splitRow(tableLines[1]); + const alignments = alignRow.map(col => { + const tc = col.trim(); + if (tc.startsWith(':') && tc.endsWith(':')) return 'center'; + if (tc.endsWith(':')) return 'right'; + return 'left'; + }); + const rows = []; + for (let r = 2; r < tableLines.length; r++) { + rows.push(splitRow(tableLines[r])); + } + blocks.push({ + type: 'table', + headers: headers, + alignments: alignments, + rows: rows + }); + continue; + } + } + + // 4. Markdown Headings: #, ##, ###, ####, #####, ###### + const headingMatch = line.match(/^(\#{1,6})\s+(.+)$/); + if (headingMatch) { + blocks.push({ + type: 'heading', + level: Math.min(6, Math.max(1, headingMatch[1].length)), + text: headingMatch[2].trim() + }); + i++; + continue; + } + + // HTML Heading: ... + const htmlHeadingMatch = line.match(/^]*)>(.*?)<\/h\1>/i); + if (htmlHeadingMatch) { + const level = parseInt(htmlHeadingMatch[1], 10); + const attrs = htmlHeadingMatch[2] || ''; + const hText = htmlHeadingMatch[3].trim(); + const align = /align=["']?center["']?/i.test(attrs) ? 'center' : ''; + blocks.push({ + type: 'heading', + level: level, + text: hText, + align: align + }); + i++; + continue; + } + + // 5. Horizontal Rule: --- or *** or ___ or
+ if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '
' || trimmed === '
' || trimmed === '
') { + blocks.push({ type: 'hr' }); + i++; + continue; + } + + // 6. Blockquote: > ... + if (trimmed.startsWith('>')) { + const quoteLines = []; + while (i < lines.length && lines[i].trim().startsWith('>')) { + quoteLines.push(lines[i].trim().replace(/^>\s?/, '')); + i++; + } + blocks.push({ + type: 'blockquote', + text: quoteLines.join(' ') + }); + continue; + } + + // 7. Unordered List: - item or * item or + item + if (/^[-*+]\s+/.test(trimmed)) { + const listItems = []; + while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) { + listItems.push(lines[i].trim().replace(/^[-*+]\s+/, '')); + i++; + } + blocks.push({ + type: 'ul', + items: listItems + }); + continue; + } + + // 8. Ordered List: 1. item + if (/^\d+\.\s+/.test(trimmed)) { + const listItems = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { + listItems.push(lines[i].trim().replace(/^\d+\.\s+/, '')); + i++; + } + blocks.push({ + type: 'ol', + items: listItems + }); + continue; + } + + // 9. Centered HTML block (

or

) + if (/^<(?:p|div)[^>]*align=["']?center["']?[^>]*>/i.test(trimmed)) { + const htmlLines = []; + while (i < lines.length) { + const cur = lines[i]; + htmlLines.push(cur.trim()); + if (/<\/(?:p|div)>/i.test(cur)) { + i++; + break; + } + i++; + } + blocks.push({ + type: 'paragraph', + text: htmlLines.join(' '), + align: 'center' + }); + continue; + } + + // 10. Regular paragraph: gather lines until next block marker or empty line + const paraLines = []; + while (i < lines.length) { + const cur = lines[i]; + const curTrim = cur.trim(); + if (!curTrim) break; + if (curTrim.startsWith('```') || + /^#{1,6}\s+/.test(curTrim) || + /^]*>/i.test(curTrim) || + (curTrim.startsWith('|') && curTrim.endsWith('|')) || + curTrim.startsWith('>') || + /^[-*+]\s+/.test(curTrim) || + /^\d+\.\s+/.test(curTrim) || + /^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) || + /^<(?:p|div)[^>]*align=["']?center/i.test(curTrim)) { + break; + } + paraLines.push(curTrim); + i++; + } + if (paraLines.length > 0) { + blocks.push({ + type: 'paragraph', + text: paraLines.join(' ') + }); + } + } + + return blocks; +} + +export function renderInteractiveSpans(container, text, contextText, knownWordsMap) { + if (!text) return; + + let processed = text + .replace(/·/g, '·') + .replace(/•/g, '•') + .replace(/—/g, '—') + .replace(/–/g, '–'); + + processed = processed.replace(/^]*>/i, '').replace(/<\/p>$/i, ''); + processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, ''); + + // Convert Markdown inline patterns into HTML + processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + processed = processed.replace(/`([^`]+)`/g, '$1'); + processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1'); + processed = processed.replace(/__([^_]+)__/g, '$1'); + processed = processed.replace(/(?$1'); + processed = processed.replace(/(?$1'); + + const tpl = document.createElement('template'); + tpl.innerHTML = processed; + + function transformDOMNode(node) { + if (node.nodeType === Node.TEXT_NODE) { + const raw = node.textContent; + if (!raw) return document.createTextNode(''); + const frag = document.createDocumentFragment(); + const tokens = raw.split(/([A-Za-z0-9'-]+)/); + for (const token of tokens) { + const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1; + if (isWord) { + const span = document.createElement('span'); + span.className = 'reader-word'; + span.dataset.word = token; + span.dataset.context = contextText || raw; + span.textContent = token; + + const lower = token.toLowerCase(); + if (knownWordsMap && knownWordsMap.has(lower)) { + const card = knownWordsMap.get(lower); + span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning'); + } + if (isHighlightEnabled) { + span.classList.add('highlight-enabled'); + } + + frag.appendChild(span); + } else { + frag.appendChild(document.createTextNode(token)); + } + } + return frag; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = node.tagName.toLowerCase(); + + if (tag === 'code' || tag === 'pre') { + const codeEl = document.createElement('code'); + codeEl.className = 'reader-inline-code'; + codeEl.textContent = node.textContent; + return codeEl; + } + + if (tag === 'img') { + const img = document.createElement('img'); + img.className = 'reader-img'; + img.src = node.getAttribute('src') || ''; + img.alt = node.getAttribute('alt') || ''; + if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width')); + if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height')); + img.addEventListener('error', () => { + img.style.display = 'none'; + }); + return img; + } + + if (tag === 'a') { + const a = document.createElement('a'); + a.className = 'reader-link'; + a.href = node.getAttribute('href') || '#'; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + Array.from(node.childNodes).forEach(child => { + a.appendChild(transformDOMNode(child)); + }); + return a; + } + + if (tag === 'br') { + return document.createElement('br'); + } + + if (tag === 'strong' || tag === 'b') { + const strong = document.createElement('strong'); + Array.from(node.childNodes).forEach(child => { + strong.appendChild(transformDOMNode(child)); + }); + return strong; + } + + if (tag === 'em' || tag === 'i') { + const em = document.createElement('em'); + Array.from(node.childNodes).forEach(child => { + em.appendChild(transformDOMNode(child)); + }); + return em; + } + + if (tag === 'sub' || tag === 'sup') { + const el = document.createElement(tag); + Array.from(node.childNodes).forEach(child => { + el.appendChild(transformDOMNode(child)); + }); + return el; + } + + const containerEl = document.createElement(tag === 'p' ? 'span' : tag); + if (node.hasAttribute('style')) { + containerEl.setAttribute('style', node.getAttribute('style')); + } + Array.from(node.childNodes).forEach(child => { + containerEl.appendChild(transformDOMNode(child)); + }); + return containerEl; + } + + return document.createTextNode(''); + } + + Array.from(tpl.content.childNodes).forEach(childNode => { + container.appendChild(transformDOMNode(childNode)); + }); +} + +export function extractContextSentence(fullText, word) { + if (!fullText) return ''; + const re = new RegExp(`([^.!?\\n]*?\\b${escapeRegex(word)}\\b[^.!?\\n]*[.!?]?)`, 'i'); + const m = fullText.match(re); + if (m && m[1]) return m[1].trim(); + return fullText.slice(0, 160).trim(); +} + +// ----------------------------------------------------------------------- +// Interactive Word Popup +// ----------------------------------------------------------------------- +export function positionWordPopup(targetElement) { + const readerWordPopup = document.getElementById('reader-word-popup'); + if (!targetElement || !readerWordPopup) return; + if (window.innerWidth <= 768) { + readerWordPopup.style.top = ''; + readerWordPopup.style.left = ''; + return; + } + const rect = targetElement.getBoundingClientRect(); + const popupWidth = 320; + const popupHeight = readerWordPopup.offsetHeight || 220; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = rect.left + (rect.width / 2) - (popupWidth / 2); + if (left + popupWidth > viewportWidth - 12) { + left = viewportWidth - popupWidth - 12; + } + if (left < 12) { + left = 12; + } + + let top = rect.bottom + 8; + if (top + popupHeight > viewportHeight - 12) { + if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) { + top = Math.max(65, rect.top - popupHeight - 8); + } + } + + readerWordPopup.style.top = `${Math.round(top)}px`; + readerWordPopup.style.left = `${Math.round(left)}px`; +} + +export async function openWordPopup(term, contextSentence, targetElement) { + const readerWordPopup = document.getElementById('reader-word-popup'); + const readerSettingsPanel = document.getElementById('reader-settings-panel'); + const popupTerm = document.getElementById('popup-term'); + const popupPhonetic = document.getElementById('popup-phonetic'); + const popupTranslation = document.getElementById('popup-translation'); + const popupContext = document.getElementById('popup-context'); + const popupLoading = document.getElementById('popup-loading'); + const popupContent = document.getElementById('popup-content'); + const popupInLibrary = document.getElementById('popup-in-library'); + const popupSaveBtn = document.getElementById('popup-save-btn'); + const popupSavedBadge = document.getElementById('popup-saved-badge'); + const popupAddContextBtn = document.getElementById('popup-add-context-btn'); + const popupAudioBtn = document.getElementById('popup-audio-btn'); + + const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim(); + if (!cleanTerm || !readerWordPopup) return; + if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden'); + + const lowerClean = cleanTerm.toLowerCase(); + const allCards = getAllCards() || []; + const existingCard = allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean); + + popupCurrentData = { + term: cleanTerm, + contextSentence: contextSentence, + targetElement: targetElement, + existingCard: existingCard, + }; + + if (popupTerm) popupTerm.textContent = cleanTerm; + if (popupPhonetic) popupPhonetic.textContent = ''; + if (popupTranslation) popupTranslation.textContent = ''; + if (popupContext) popupContext.textContent = contextSentence ? `“${contextSentence}”` : ''; + if (popupLoading) popupLoading.classList.remove('hidden'); + if (popupContent) popupContent.classList.add('hidden'); + + if (popupInLibrary) { + popupInLibrary.classList.toggle('hidden', !existingCard); + } + + if (existingCard) { + if (popupSaveBtn) popupSaveBtn.classList.add('hidden'); + if (popupSavedBadge) { + popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; + } + if (popupAddContextBtn) { + if (contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) { + popupAddContextBtn.classList.remove('hidden'); + } else { + popupAddContextBtn.classList.add('hidden'); + } + } + } else { + if (popupSavedBadge) popupSavedBadge.classList.add('hidden'); + if (popupSaveBtn) { + popupSaveBtn.classList.remove('hidden'); + popupSaveBtn.disabled = false; + } + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); + } + + readerWordPopup.classList.remove('hidden'); + positionWordPopup(targetElement); + + try { + const data = await api.lookup.search(cleanTerm); + popupCurrentData.lookup = data; + + const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : ''); + if (popupPhonetic) popupPhonetic.textContent = data.phonetic || ''; + if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден'; + if (popupLoading) popupLoading.classList.add('hidden'); + if (popupContent) popupContent.classList.remove('hidden'); + if (popupAudioBtn) popupAudioBtn.classList.add('hidden'); + + const inLib = Boolean(data.in_library || existingCard); + const libCard = data.library_card || existingCard; + if (inLib) { + popupCurrentData.existingCard = libCard; + if (popupInLibrary) popupInLibrary.classList.remove('hidden'); + if (popupSaveBtn) popupSaveBtn.classList.add('hidden'); + if (popupSavedBadge) { + popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = '⭐ Слово уже в библиотеке'; + } + if (popupAddContextBtn) { + if (contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) { + popupAddContextBtn.classList.remove('hidden'); + } else { + popupAddContextBtn.classList.add('hidden'); + } + } + } else { + if (popupInLibrary) popupInLibrary.classList.add('hidden'); + if (popupSaveBtn) popupSaveBtn.classList.remove('hidden'); + if (popupSavedBadge) popupSavedBadge.classList.add('hidden'); + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); + } + + positionWordPopup(targetElement); + } catch (e) { + console.error('Popup lookup error:', e); + if (popupTranslation) popupTranslation.textContent = 'Сетевая ошибка'; + if (popupLoading) popupLoading.classList.add('hidden'); + if (popupContent) popupContent.classList.remove('hidden'); + positionWordPopup(targetElement); + } +} + +export async function handleWordClick(word, paragraphText, targetElement) { + const sentence = extractContextSentence(paragraphText, word); + openWordPopup(word, sentence, targetElement); +} + +function updatePopupPositionOnScroll() { + const readerWordPopup = document.getElementById('reader-word-popup'); + if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return; + if (window.innerWidth <= 768) return; + if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) { + readerWordPopup.classList.add('hidden'); + return; + } + const rect = popupCurrentData.targetElement.getBoundingClientRect(); + const headerOffset = 65; + if (rect.bottom < headerOffset || rect.top > window.innerHeight) { + readerWordPopup.classList.add('hidden'); + return; + } + positionWordPopup(popupCurrentData.targetElement); +} + +function schedulePopupUpdate() { + const readerWordPopup = document.getElementById('reader-word-popup'); + if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return; + if (popupScrollRaf) return; + popupScrollRaf = requestAnimationFrame(() => { + popupScrollRaf = null; + updatePopupPositionOnScroll(); + }); +} + +// ----------------------------------------------------------------------- +// Scroll Position Tracking & Metrics +// ----------------------------------------------------------------------- +export function getReaderScrollMetrics() { + const readerScrollArea = document.getElementById('reader-scroll-area'); + 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 }; +} + +export function getActiveAnchorIndex() { + const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerContentBody = document.getElementById('reader-content-body'); + if (!readerScrollArea || !readerContentBody) return null; + const blocks = readerContentBody.querySelectorAll('[data-block-idx]'); + if (!blocks || blocks.length === 0) return null; + const containerTop = readerScrollArea.getBoundingClientRect().top; + for (let el of blocks) { + const rect = el.getBoundingClientRect(); + if (rect.bottom >= containerTop + 10) { + return parseInt(el.getAttribute('data-block-idx'), 10); + } + } + return 0; +} + +export function getActiveVisibleChapter() { + const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerContentBody = document.getElementById('reader-content-body'); + if (!readerScrollArea || !readerContentBody) return currentChapterIndex; + const dividers = readerContentBody.querySelectorAll('[data-chapter-idx]'); + if (!dividers || dividers.length === 0) return currentChapterIndex; + + const containerTop = readerScrollArea.getBoundingClientRect().top; + let activeIdx = currentChapterIndex; + + for (let el of dividers) { + const rect = el.getBoundingClientRect(); + if (rect.top <= containerTop + 140) { + const ch = parseInt(el.getAttribute('data-chapter-idx'), 10); + if (!isNaN(ch)) activeIdx = ch; + } else { + break; + } + } + return activeIdx; +} + +export function updateReaderProgressBar() { + const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill'); + const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge'); + const { pct, fraction } = getReaderScrollMetrics(); + + if (readerSettings.flowMode === 'continuous') { + if (readerChapterProgressFill) { + readerChapterProgressFill.style.width = `${pct}%`; + } + if (readerScrollPctBadge) { + readerScrollPctBadge.textContent = `${pct}%`; + const totalCh = currentBook && currentBook.chapters ? currentBook.chapters.length : 1; + readerScrollPctBadge.title = `Вся книга: ${pct}% • Глава ${currentChapterIndex + 1} из ${totalCh}`; + } + return; + } + + if (readerChapterProgressFill) { + readerChapterProgressFill.style.width = `${pct}%`; + } + + if (currentBook) { + const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1)); + const overallFraction = (currentChapterIndex + fraction) / totalCh; + const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100))); + + if (readerScrollPctBadge) { + readerScrollPctBadge.textContent = `${pct}%`; + readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`; + } + } else if (readerScrollPctBadge) { + readerScrollPctBadge.textContent = `${pct}%`; + } +} + +export function handleReaderScroll() { + const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerChapterSelect = document.getElementById('reader-chapter-select'); + const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); + const readerNextChBtn = document.getElementById('reader-next-ch-btn'); + + if (!currentBook || !readerScrollArea) return; + updateReaderProgressBar(); + + // 1. Detect active visible chapter in continuous or multi-chapter view + if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.size > 1) { + const activeCh = getActiveVisibleChapter(); + if (activeCh !== null && activeCh !== undefined && activeCh !== currentChapterIndex) { + currentChapterIndex = activeCh; + if (readerChapterSelect) readerChapterSelect.value = currentChapterIndex; + if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0; + if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; + + const desiredHash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex}`; + if (window.location.hash !== desiredHash) { + history.replaceState(null, '', desiredHash); + } + } + } + + // 2. Infinite scroll: auto-append next chapter in continuous mode or chapters mode with autoload + if ((readerSettings.flowMode === 'continuous' || (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext)) && !isAppendingChapter) { + const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight); + if (distanceToBottom < 700) { + const loadedArr = Array.from(loadedChaptersSet); + const highestCh = loadedArr.length > 0 ? Math.max(...loadedArr) : currentChapterIndex; + if (currentBook && currentBook.chapters && highestCh < currentBook.chapters.length - 1) { + isAppendingChapter = true; + appendNextChapter(highestCh + 1, false); + setTimeout(() => { isAppendingChapter = false; }, 200); + } + } + } + + const { scrollTop, fraction } = getReaderScrollMetrics(); + const anchorIdx = getActiveAnchorIndex(); + + // 3. Instant local persistence + try { + const key = `notes_book_${currentBook.id}_pos`; + localStorage.setItem(key, JSON.stringify({ + chapter: currentChapterIndex, + scrollTop: scrollTop, + fraction: fraction, + anchorIdx: anchorIdx, + savedAt: Date.now() + })); + localStorage.setItem('notes_active_book_id', currentBook.id); + localStorage.setItem('notes_active_view', 'reader-view'); + } catch (e) {} + + // 4. Debounced server sync (every 800ms) + clearTimeout(readerScrollSaveTimer); + readerScrollSaveTimer = setTimeout(() => { + if (!currentBook) return; + api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: fraction }) + .catch(err => console.log('Progress save error:', err)); + }, 800); +} + +// ----------------------------------------------------------------------- +// Reader Appearance & Settings +// ----------------------------------------------------------------------- +export function loadSavedReaderSettings() { + try { + const saved = localStorage.getItem('notes_reader_settings'); + if (saved) { + readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved)); + } + } catch (e) {} +} + +export function saveReaderSettings() { + try { + localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings)); + } catch (e) {} +} + +export function applyReaderSettings() { + const readerScrollArea = document.getElementById('reader-scroll-area'); + const settingFontSizeVal = document.getElementById('setting-font-size-val'); + const readerContentBody = document.getElementById('reader-content-body'); + if (!readerScrollArea) return; + + const anchorIdx = getActiveAnchorIndex(); + + // 1. Font size + readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`); + if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; + + // 2. Line height + readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight); + + // 3. Theme + readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled'); + readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`); + + // 4. Font family + readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono'); + readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`); + + // 5. Margin width + readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide'); + readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`); + + // 6. Text align + readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify'); + readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`); + + updateReaderSettingsUI(); + + // Re-anchor scroll position after font/layout change + if (anchorIdx !== null && anchorIdx !== undefined && readerContentBody) { + requestAnimationFrame(() => { + const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`); + if (el) { + const containerTop = readerScrollArea.getBoundingClientRect().top; + const elTop = el.getBoundingClientRect().top; + readerScrollArea.scrollTop += (elTop - containerTop - 10); + } + }); + } +} + +export function updateReaderSettingsUI() { + const readerSettingsPanel = document.getElementById('reader-settings-panel'); + const settingFontSizeVal = document.getElementById('setting-font-size-val'); + const readerFontFamilyGroup = document.getElementById('reader-font-family-group'); + const readerLineHeightGroup = document.getElementById('reader-line-height-group'); + const readerMarginsGroup = document.getElementById('reader-margins-group'); + const readerThemeGroup = document.getElementById('reader-theme-group'); + const readerAlignGroup = document.getElementById('reader-align-group'); + const readerModeGroup = document.getElementById('reader-mode-group'); + const readerAutoloadGroup = document.getElementById('reader-autoload-group'); + const settingAutoloadRow = document.getElementById('setting-autoload-row'); + + if (!readerSettingsPanel) return; + + if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`; + + if (readerFontFamilyGroup) { + readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily); + }); + } + + if (readerLineHeightGroup) { + readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05); + }); + } + + if (readerMarginsGroup) { + readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth); + }); + } + + if (readerThemeGroup) { + readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => { + btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme); + }); + } + + if (readerAlignGroup) { + readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign); + }); + } + + if (readerModeGroup) { + readerModeGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.flow === readerSettings.flowMode); + }); + } + + if (settingAutoloadRow) { + settingAutoloadRow.style.display = (readerSettings.flowMode === 'chapters') ? '' : 'none'; + } + + if (readerAutoloadGroup) { + readerAutoloadGroup.querySelectorAll('.seg-btn').forEach(btn => { + btn.classList.toggle('active', (btn.dataset.autoload === 'true') === readerSettings.autoLoadNext); + }); + } +} + +export function positionSettingsPanel() { + const readerSettingsPanel = document.getElementById('reader-settings-panel'); + const readerSettingsBtn = document.getElementById('reader-settings-btn'); + if (!readerSettingsPanel || !readerSettingsBtn) return; + if (window.innerWidth <= 768) { + readerSettingsPanel.style.top = ''; + readerSettingsPanel.style.left = ''; + readerSettingsPanel.style.right = ''; + readerSettingsPanel.style.maxHeight = ''; + return; + } + const rect = readerSettingsBtn.getBoundingClientRect(); + const panelWidth = 330; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = rect.right - panelWidth; + if (left < 16) left = 16; + if (left + panelWidth > viewportWidth - 16) { + left = viewportWidth - panelWidth - 16; + } + + const top = Math.round(rect.bottom + 8); + const maxH = Math.max(260, viewportHeight - top - 16); + + readerSettingsPanel.style.top = `${top}px`; + readerSettingsPanel.style.left = `${Math.round(left)}px`; + readerSettingsPanel.style.right = 'auto'; + readerSettingsPanel.style.maxHeight = `${maxH}px`; +} + +// ----------------------------------------------------------------------- +// Reader Module Initialization +// ----------------------------------------------------------------------- +export function initReader() { + loadSavedReaderSettings(); + applyReaderSettings(); + + const readerSettingsBtn = document.getElementById('reader-settings-btn'); + const readerSettingsPanel = document.getElementById('reader-settings-panel'); + const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn'); + const readerWordPopup = document.getElementById('reader-word-popup'); + const settingFontDecBtn = document.getElementById('setting-font-dec-btn'); + const settingFontIncBtn = document.getElementById('setting-font-inc-btn'); + const readerFontFamilyGroup = document.getElementById('reader-font-family-group'); + const readerLineHeightGroup = document.getElementById('reader-line-height-group'); + const readerMarginsGroup = document.getElementById('reader-margins-group'); + const readerThemeGroup = document.getElementById('reader-theme-group'); + const readerAlignGroup = document.getElementById('reader-align-group'); + const readerModeGroup = document.getElementById('reader-mode-group'); + const readerAutoloadGroup = document.getElementById('reader-autoload-group'); + + const readerContentBody = document.getElementById('reader-content-body'); + const readerScrollArea = document.getElementById('reader-scroll-area'); + const readerChapterSelect = document.getElementById('reader-chapter-select'); + const readerPrevChBtn = document.getElementById('reader-prev-ch-btn'); + const readerNextChBtn = document.getElementById('reader-next-ch-btn'); + const readerBackBtn = document.getElementById('reader-back-btn'); + const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn'); + const readerTrainBtn = document.getElementById('reader-train-btn'); + + const popupCloseBtn = document.getElementById('popup-close-btn'); + const popupSaveBtn = document.getElementById('popup-save-btn'); + const popupSavedBadge = document.getElementById('popup-saved-badge'); + const popupInLibrary = document.getElementById('popup-in-library'); + const popupAddContextBtn = document.getElementById('popup-add-context-btn'); + const popupTranslation = document.getElementById('popup-translation'); + + const addTextBtn = document.getElementById('add-text-btn'); + const addBookModal = document.getElementById('add-book-modal'); + const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn'); + const cancelAddBookBtn = document.getElementById('cancel-add-book-btn'); + const addBookForm = document.getElementById('add-book-form'); + const bookTitleInput = document.getElementById('book-title-input'); + const bookAuthorInput = document.getElementById('book-author-input'); + const bookContentInput = document.getElementById('book-content-input'); + const bookFileInput = document.getElementById('book-file-input'); + + // Settings panel triggers + if (readerSettingsBtn) { + readerSettingsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (readerWordPopup) readerWordPopup.classList.add('hidden'); + if (readerSettingsPanel) { + const isHidden = readerSettingsPanel.classList.contains('hidden'); + readerSettingsPanel.classList.toggle('hidden', !isHidden); + if (isHidden) { + updateReaderSettingsUI(); + positionSettingsPanel(); + } + } + }); + } + + if (readerSettingsCloseBtn) { + readerSettingsCloseBtn.addEventListener('click', () => { + if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden'); + }); + } + + if (settingFontDecBtn) { + settingFontDecBtn.addEventListener('click', () => { + if (readerSettings.fontSize > 13) { + readerSettings.fontSize -= 1; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (settingFontIncBtn) { + settingFontIncBtn.addEventListener('click', () => { + if (readerSettings.fontSize < 34) { + readerSettings.fontSize += 1; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerFontFamilyGroup) { + readerFontFamilyGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.font) { + readerSettings.fontFamily = btn.dataset.font; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerLineHeightGroup) { + readerLineHeightGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.lh) { + readerSettings.lineHeight = parseFloat(btn.dataset.lh); + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerMarginsGroup) { + readerMarginsGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.margin) { + readerSettings.marginWidth = btn.dataset.margin; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerThemeGroup) { + readerThemeGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.theme-chip'); + if (btn && btn.dataset.theme) { + readerSettings.theme = btn.dataset.theme; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerAlignGroup) { + readerAlignGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.align) { + readerSettings.textAlign = btn.dataset.align; + applyReaderSettings(); + saveReaderSettings(); + } + }); + } + + if (readerModeGroup) { + readerModeGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.flow) { + if (readerSettings.flowMode !== btn.dataset.flow) { + readerSettings.flowMode = btn.dataset.flow; + saveReaderSettings(); + updateReaderSettingsUI(); + if (currentBook) { + renderChapter(currentChapterIndex, 0, 0, 0); + } + } + } + }); + } + + if (readerAutoloadGroup) { + readerAutoloadGroup.addEventListener('click', (e) => { + const btn = e.target.closest('.seg-btn'); + if (btn && btn.dataset.autoload !== undefined) { + readerSettings.autoLoadNext = (btn.dataset.autoload === 'true'); + saveReaderSettings(); + updateReaderSettingsUI(); + const footerHint = readerContentBody ? readerContentBody.querySelector('.chapter-footer-hint') : null; + if (footerHint) { + footerHint.textContent = (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) + ? '⚡ Автоподгрузка включена: следующая глава подгрузится автоматически при скролле вниз' + : '💡 Нажмите кнопку выше для перехода к следующей главе'; + } + } + }); + } + + document.addEventListener('click', (e) => { + if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) { + if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) { + readerSettingsPanel.classList.add('hidden'); + } + } + }); + + window.addEventListener('resize', () => { + if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) { + positionSettingsPanel(); + } + }, { passive: true }); + + window.addEventListener('scroll', () => { + if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden') && window.innerWidth > 768) { + positionSettingsPanel(); + } + }, { passive: true }); + + // Word clicking & highlighting + if (readerContentBody) { + readerContentBody.addEventListener('click', (e) => { + const wordSpan = e.target.closest('.reader-word'); + if (wordSpan) { + e.stopPropagation(); + const word = wordSpan.dataset.word; + const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word; + handleWordClick(word, context, wordSpan); + } + }); + + // Phrase/chunk selection in reader + readerContentBody.addEventListener('mouseup', () => { + const sel = window.getSelection(); + const text = sel.toString().trim(); + if (text && text.split(/\s+/).length >= 2 && text.length <= 80) { + const range = sel.getRangeAt(0); + const rect = range.getBoundingClientRect(); + openWordPopup(text, text, { + getBoundingClientRect: () => rect, + }); + } + }); + } + + if (readerToggleHighlightBtn) { + readerToggleHighlightBtn.addEventListener('click', () => { + isHighlightEnabled = !isHighlightEnabled; + readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled); + if (readerContentBody) { + readerContentBody.querySelectorAll('.reader-word').forEach(w => { + w.classList.toggle('highlight-enabled', isHighlightEnabled); + }); + } + }); + } + + if (readerTrainBtn) { + readerTrainBtn.addEventListener('click', () => { + if (currentBook) { + const bookTag = slugify(currentBook.title); + window.location.hash = `#srs/${encodeURIComponent(bookTag)}`; + } + }); + } + + // Popup actions + if (popupCloseBtn) { + popupCloseBtn.addEventListener('click', () => { + if (readerWordPopup) readerWordPopup.classList.add('hidden'); + }); + } + + document.addEventListener('click', (e) => { + if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) { + readerWordPopup.classList.add('hidden'); + } + }); + + window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true }); + window.addEventListener('resize', schedulePopupUpdate, { passive: true }); + + if (popupSaveBtn) { + popupSaveBtn.addEventListener('click', async () => { + if (!popupCurrentData) return; + const d = popupCurrentData; + const lookup = d.lookup || {}; + const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; + + const payload = { + term: d.term, + translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''), + context_sentence: d.contextSentence || '', + phonetic: lookup.phonetic || '', + audio_url: lookup.audio_url || '', + part_of_speech: lookup.part_of_speech || '', + definition_en: lookup.definition_en || '', + collection: 'Книги & Сериалы', + tags: `${bookTag}, reader`, + notes: currentBook ? `Из книги: ${currentBook.title}` : '', + }; + + try { + const resp = await api.cards.create(payload); + if (resp.ok) { + const resData = await resp.json(); + popupSaveBtn.classList.add('hidden'); + if (popupSavedBadge) { + popupSavedBadge.classList.remove('hidden'); + popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!'; + } + if (popupInLibrary) popupInLibrary.classList.remove('hidden'); + if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden'); + + if (d.targetElement) { + d.targetElement.classList.add('status-learning'); + if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled'); + } + + await fetchCards(); + } else { + alert('Не удалось сохранить слово'); + } + } catch (e) { + console.error('Save card from reader error:', e); + } + }); + } + + if (popupAddContextBtn) { + popupAddContextBtn.addEventListener('click', async () => { + if (!popupCurrentData) return; + const d = popupCurrentData; + const lookup = d.lookup || {}; + const bookTag = currentBook ? slugify(currentBook.title) : 'reader'; + const payload = { + term: d.term, + translation: lookup.translation || lookup.translation_ru || (popupTranslation ? popupTranslation.textContent : ''), + context_sentence: d.contextSentence || '', + phonetic: lookup.phonetic || '', + audio_url: lookup.audio_url || '', + part_of_speech: lookup.part_of_speech || '', + definition_en: lookup.definition_en || '', + collection: 'Книги & Сериалы', + tags: `${bookTag}, reader`, + notes: currentBook ? `Из книги: ${currentBook.title}` : '', + }; + + popupAddContextBtn.disabled = true; + popupAddContextBtn.textContent = 'Сохраняем...'; + + try { + const resp = await api.cards.create(payload); + if (resp.ok) { + popupAddContextBtn.classList.add('hidden'); + if (popupSavedBadge) { + popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!'; + popupSavedBadge.classList.remove('hidden'); + } + await fetchCards(); + } else { + alert('Не удалось обновить контекст'); + } + } catch (e) { + console.error('Add context error:', e); + } finally { + popupAddContextBtn.disabled = false; + popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке'; + } + }); + } + + // Chapter Navigation & Reader Toolbar + if (readerChapterSelect) { + readerChapterSelect.addEventListener('change', () => { + const ch = parseInt(readerChapterSelect.value, 10); + if (!currentBook) return; + if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(ch)) { + currentChapterIndex = ch; + const target = document.getElementById(`chapter-anchor-${ch}`) + || (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`)); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + } + window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`; + }); + } + + if (readerPrevChBtn) { + readerPrevChBtn.addEventListener('click', () => { + if (currentBook && currentChapterIndex > 0) { + const prev = currentChapterIndex - 1; + if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(prev)) { + currentChapterIndex = prev; + if (readerChapterSelect) readerChapterSelect.value = prev; + const target = document.getElementById(`chapter-anchor-${prev}`) + || (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`)); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + } + window.location.hash = `#reader/book/${currentBook.id}/ch/${prev}`; + } + }); + } + + if (readerNextChBtn) { + readerNextChBtn.addEventListener('click', () => { + if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) { + const next = currentChapterIndex + 1; + if (readerSettings.flowMode === 'continuous' || loadedChaptersSet.has(next)) { + currentChapterIndex = next; + if (readerChapterSelect) readerChapterSelect.value = next; + const target = document.getElementById(`chapter-anchor-${next}`) + || (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${next}"]`)); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + } + if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext) { + appendNextChapter(next, true); + } else { + window.location.hash = `#reader/book/${currentBook.id}/ch/${next}`; + } + } + }); + } + + if (readerBackBtn) { + readerBackBtn.addEventListener('click', () => { + window.location.hash = '#reader'; + }); + } + + if (readerScrollArea) { + readerScrollArea.addEventListener('scroll', handleReaderScroll, { passive: true }); + } + + window.addEventListener('beforeunload', () => { + if (currentBook && readerScrollArea) { + const { scrollTop, fraction } = getReaderScrollMetrics(); + const anchorIdx = getActiveAnchorIndex(); + try { + localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({ + chapter: currentChapterIndex, + scrollTop: scrollTop, + fraction: fraction, + anchorIdx: anchorIdx, + 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); + } + } + }); + + // Bookshelf Add/Upload Actions + if (addTextBtn && addBookModal && addBookForm) { + addTextBtn.addEventListener('click', () => { + addBookForm.reset(); + addBookModal.classList.remove('hidden'); + }); + } + + if (closeAddBookModalBtn && addBookModal) { + closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden')); + } + if (cancelAddBookBtn && addBookModal) { + cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden')); + } + + if (addBookForm) { + addBookForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const title = bookTitleInput ? bookTitleInput.value.trim() : ''; + const author = bookAuthorInput ? bookAuthorInput.value.trim() : ''; + const content = bookContentInput ? bookContentInput.value.trim() : ''; + + if (!title || !content) return; + + try { + const resp = await api.books.createManual({ + title: title, + author: author, + content: content, + source_type: 'text', + }); + + if (resp.ok) { + const book = await resp.json(); + if (addBookModal) addBookModal.classList.add('hidden'); + window.location.hash = `#reader/book/${book.id}`; + } else { + alert('Ошибка при сохранении книги'); + } + } catch (err) { + console.error('Book create error:', err); + } + }); + } + + if (bookFileInput) { + bookFileInput.addEventListener('change', async () => { + const file = bookFileInput.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const resp = await api.books.uploadFile(formData); + + if (resp.ok) { + const book = await resp.json(); + bookFileInput.value = ''; + window.location.hash = `#reader/book/${book.id}`; + } else { + let errMsg = 'Не удалось обработать файл'; + try { + const errData = await resp.json(); + errMsg = errData.detail || errMsg; + } catch (_) { + if (resp.status === 413) { + errMsg = 'Файл слишком большой (превышен лимит размера в Nginx)'; + } else { + errMsg = `Код ответа сервера: HTTP ${resp.status}`; + } + } + alert(`Ошибка загрузки: ${errMsg}`); + } + } catch (e) { + console.error('File upload error:', e); + alert('Ошибка сети или соединение прервано при загрузке файла'); + } + }); + } +} diff --git a/app/static/js/modules/srs.js b/app/static/js/modules/srs.js new file mode 100644 index 0000000..a662d84 --- /dev/null +++ b/app/static/js/modules/srs.js @@ -0,0 +1,735 @@ +// ======================================================================= +// Notes Application - SRS Training & Analytics Module (Native ES Module) +// ======================================================================= + +import { api } from './api.js'; +import { escapeHtml, escapeRegex, shuffleArray, showToast, openModal, closeModal, playAudio } from './ui.js'; +import { getAllCards } from './dictionary.js'; + +let srsQueue = []; +let srsCurrentIndex = 0; +let srsCurrentMode = 'flashcards'; // 'flashcards' | 'quiz' | 'typing' | 'cloze' | 'dictation' +let srsDirection = 'en-ru'; +let srsSmartMix = true; +let srsSessionRatings = []; +let srsIsFlipped = false; +let quizCurrentOptions = []; +let quizAnswered = false; + +export function initSrs() { + const srsView = document.getElementById('srs-view'); + const srsExitBtn = document.getElementById('srs-exit-btn'); + const srsTabs = document.querySelectorAll('.srs-tab'); + const srsProgressFill = document.getElementById('srs-progress-fill'); + const srsCounter = document.getElementById('srs-counter'); + const srsDirBtn = document.getElementById('srs-dir-btn'); + const srsDirLabel = document.getElementById('srs-dir-label'); + const srsMixBtn = document.getElementById('srs-mix-btn'); + const srsMixLabel = document.getElementById('srs-mix-label'); + + const srsLoading = document.getElementById('srs-loading'); + const srsEmpty = document.getElementById('srs-empty'); + const srsTrainAllBtn = document.getElementById('srs-train-all-btn'); + const srsEmptyBackBtn = document.getElementById('srs-empty-back-btn'); + + // Mode 1: Flashcards + const srsModeFlashcards = document.getElementById('srs-mode-flashcards'); + const flashcardFlipWrap = document.getElementById('flashcard-flip-wrap'); + const flashcardInner = document.getElementById('flashcard-inner'); + const fcFrontDirBadge = document.getElementById('fc-front-dir-badge'); + const fcFrontPos = document.getElementById('fc-front-pos'); + const fcFrontMain = document.getElementById('fc-front-main'); + const fcFrontPhoneticWrap = document.getElementById('fc-front-phonetic-wrap'); + const fcFrontPhonetic = document.getElementById('fc-front-phonetic'); + const fcFrontAudioBtn = document.getElementById('fc-front-audio-btn'); + const fcHintBtn = document.getElementById('fc-hint-btn'); + const fcHintBox = document.getElementById('fc-hint-box'); + const fcBackPos = document.getElementById('fc-back-pos'); + const fcBackTerm = document.getElementById('fc-back-term'); + const fcBackAudioBtn = document.getElementById('fc-back-audio-btn'); + const fcBackTranslation = document.getElementById('fc-back-translation'); + const fcBackDefBox = document.getElementById('fc-back-def-box'); + const fcBackDef = document.getElementById('fc-back-def'); + const fcBackContextBox = document.getElementById('fc-back-context-box'); + const fcBackContext = document.getElementById('fc-back-context'); + const fcBackNotesBox = document.getElementById('fc-back-notes-box'); + const fcBackNotes = document.getElementById('fc-back-notes'); + const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap'); + const fcFlipBtn = document.getElementById('fc-flip-btn'); + const fcRatingBar = document.getElementById('fc-rating-bar'); + + // Mode 2: Multiple Choice Quiz + const srsModeQuiz = document.getElementById('srs-mode-quiz'); + const quizPos = document.getElementById('quiz-pos'); + const quizTermDisplay = document.getElementById('quiz-term-display'); + const quizPhoneticDisplay = document.getElementById('quiz-phonetic-display'); + const quizContextHint = document.getElementById('quiz-context-hint'); + const quizAudioBtn = document.getElementById('quiz-audio-btn'); + const quizOptionsGrid = document.getElementById('quiz-options-grid'); + const quizFeedbackBox = document.getElementById('quiz-feedback-box'); + const quizDetailsBox = document.getElementById('quiz-details-box'); + const quizNextBar = document.getElementById('quiz-next-bar'); + const quizNextBtn = document.getElementById('quiz-next-btn'); + + // Mode 3: Active Typing + const srsModeTyping = document.getElementById('srs-mode-typing'); + const typingPos = document.getElementById('typing-pos'); + const typingPromptRu = document.getElementById('typing-prompt-ru'); + const typingContextHint = document.getElementById('typing-context-hint'); + const typingForm = document.getElementById('typing-form'); + const typingInput = document.getElementById('typing-input'); + const typingFeedbackBox = document.getElementById('typing-feedback-box'); + const typingDetailsBox = document.getElementById('typing-details-box'); + const typingRatingBar = document.getElementById('typing-rating-bar'); + + // Mode 4: Cloze Deletion + const srsModeCloze = document.getElementById('srs-mode-cloze'); + const clozePos = document.getElementById('cloze-pos'); + const clozeTargetTranslation = document.getElementById('cloze-target-translation'); + const clozeSentenceDisplay = document.getElementById('cloze-sentence-display'); + const clozeForm = document.getElementById('cloze-form'); + const clozeInput = document.getElementById('cloze-input'); + const clozeFeedbackBox = document.getElementById('cloze-feedback-box'); + const clozeDetailsBox = document.getElementById('cloze-details-box'); + const clozeRatingBar = document.getElementById('cloze-rating-bar'); + + // Mode 5: Dictation + const srsModeDictation = document.getElementById('srs-mode-dictation'); + const dictationBigPlayBtn = document.getElementById('dictation-big-play-btn'); + const dictationReplayBtn = document.getElementById('dictation-replay-btn'); + const dictationToggleHintBtn = document.getElementById('dictation-toggle-hint-btn'); + const dictationHintText = document.getElementById('dictation-hint-text'); + const dictationForm = document.getElementById('dictation-form'); + const dictationInput = document.getElementById('dictation-input'); + const dictationFeedbackBox = document.getElementById('dictation-feedback-box'); + const dictationDetailsBox = document.getElementById('dictation-details-box'); + const dictationRatingBar = document.getElementById('dictation-rating-bar'); + + // Summary + const srsSummary = document.getElementById('srs-summary'); + const sumCount = document.getElementById('sum-count'); + const sumRetention = document.getElementById('sum-retention'); + const sumRepeatBtn = document.getElementById('sum-repeat-btn'); + const sumReturnBtn = document.getElementById('sum-return-btn'); + + // Stats & Heatmap Modal + const statsOpenBtn = document.getElementById('stats-open-btn'); + const mobileStatsBtn = document.getElementById('mobile-stats-btn'); + const statsModal = document.getElementById('stats-modal'); + const closeStatsModalBtn = document.getElementById('close-stats-modal-btn'); + const closeStatsBtn = document.getElementById('close-stats-btn'); + const kpiCurrentStreak = document.getElementById('kpi-current-streak'); + const kpiLongestStreak = document.getElementById('kpi-longest-streak'); + const kpiRetentionRate = document.getElementById('kpi-retention-rate'); + const kpiTotalReviews = document.getElementById('kpi-total-reviews'); + const kpiTotalWords = document.getElementById('kpi-total-words'); + const kpiTodayAdded = document.getElementById('kpi-today-added'); + const kpiCefrLevel = document.getElementById('kpi-cefr-level'); + const kpiCefrDesc = document.getElementById('kpi-cefr-desc'); + const cntMastered = document.getElementById('cnt-mastered'); + const cntReview = document.getElementById('cnt-review'); + const cntLearning = document.getElementById('cnt-learning'); + const cntNew = document.getElementById('cnt-new'); + const cntLeeches = document.getElementById('cnt-leeches'); + const progMastered = document.getElementById('prog-mastered'); + const progReview = document.getElementById('prog-review'); + const progLearning = document.getElementById('prog-learning'); + const progNew = document.getElementById('prog-new'); + const heatmapGrid = document.getElementById('heatmap-grid'); + + // ----------------------------------------------------------------------- + // View & Queue Controls + // ----------------------------------------------------------------------- + srsExitBtn.addEventListener('click', () => { + window.location.hash = '#dictionary'; + }); + + srsMixBtn.addEventListener('click', () => { + srsSmartMix = !srsSmartMix; + srsMixLabel.textContent = srsSmartMix ? '🎲 Микс дня: ВКЛ' : '📚 Обычная очередь'; + srsMixBtn.classList.toggle('text-success', srsSmartMix); + startSrsTrainingSession(false); + }); + + srsTabs.forEach(tab => { + tab.addEventListener('click', () => { + srsTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + srsCurrentMode = tab.dataset.mode; + renderSrsCurrentCard(); + }); + }); + + srsDirBtn.addEventListener('click', () => { + srsDirection = srsDirection === 'en-ru' ? 'ru-en' : 'en-ru'; + srsDirLabel.textContent = srsDirection === 'en-ru' ? 'EN ➔ RU' : 'RU ➔ EN'; + renderSrsCurrentCard(); + }); + + async function startSrsTrainingSession(forceAll = false, tagFilter = null) { + srsLoading.classList.remove('hidden'); + srsEmpty.classList.add('hidden'); + srsSummary.classList.add('hidden'); + hideAllSrsModeContainers(); + + srsSessionRatings = []; + srsCurrentIndex = 0; + + let queue = []; + try { + const data = await api.srs.getQueue(forceAll, tagFilter); + queue = data.cards || []; + } catch (e) { + console.error('SRS Queue error:', e); + } + + const cards = getAllCards(); + if (queue.length === 0 && forceAll && cards.length > 0) { + queue = [...cards]; + } + + srsLoading.classList.add('hidden'); + + if (queue.length === 0) { + srsEmpty.classList.remove('hidden'); + return; + } + + srsQueue = queue; + renderSrsCurrentCard(); + } + + function closeSrsView() { + window.location.hash = '#dictionary'; + } + + srsTrainAllBtn.addEventListener('click', () => startSrsTrainingSession(true)); + srsEmptyBackBtn.addEventListener('click', closeSrsView); + sumRepeatBtn.addEventListener('click', () => startSrsTrainingSession(true)); + sumReturnBtn.addEventListener('click', closeSrsView); + + function hideAllSrsModeContainers() { + srsModeFlashcards.classList.add('hidden'); + srsModeQuiz.classList.add('hidden'); + srsModeTyping.classList.add('hidden'); + srsModeCloze.classList.add('hidden'); + srsModeDictation.classList.add('hidden'); + } + + function renderSrsCurrentCard() { + if (srsCurrentIndex >= srsQueue.length) { + showSrsSummary(); + return; + } + + srsEmpty.classList.add('hidden'); + srsSummary.classList.add('hidden'); + hideAllSrsModeContainers(); + + const total = srsQueue.length; + const currentNum = srsCurrentIndex + 1; + srsCounter.textContent = `Карточка ${currentNum} из ${total}`; + const pct = Math.round(((currentNum - 1) / total) * 100); + srsProgressFill.style.width = `${pct}%`; + + const card = srsQueue[srsCurrentIndex]; + + if (srsCurrentMode === 'flashcards') { + renderFlashcardMode(card); + } else if (srsCurrentMode === 'quiz') { + renderQuizMode(card); + } else if (srsCurrentMode === 'typing') { + renderTypingMode(card); + } else if (srsCurrentMode === 'cloze') { + renderClozeMode(card); + } else if (srsCurrentMode === 'dictation') { + renderDictationMode(card); + } + } + + // ----------------------------------------------------------------------- + // Mode 1: Flashcards + // ----------------------------------------------------------------------- + function renderFlashcardMode(card) { + srsModeFlashcards.classList.remove('hidden'); + srsIsFlipped = false; + flashcardInner.classList.remove('is-flipped'); + fcRatingBar.classList.add('hidden'); + fcHintBox.classList.add('hidden'); + fcFlipBtnWrap.style.display = 'block'; + + if (srsDirection === 'en-ru') { + fcFrontDirBadge.textContent = 'EN ➔ RU'; + fcFrontMain.textContent = card.term; + fcFrontPos.textContent = card.part_of_speech || ''; + fcFrontPos.style.display = card.part_of_speech ? 'inline-block' : 'none'; + + if (card.phonetic) { + fcFrontPhonetic.textContent = card.phonetic; + fcFrontPhoneticWrap.style.display = 'flex'; + } else { + fcFrontPhoneticWrap.style.display = 'none'; + } + + fcHintBtn.style.display = card.context_sentence ? 'inline-flex' : 'none'; + fcHintBox.textContent = card.context_sentence ? `“${card.context_sentence}”` : ''; + + fcBackTerm.textContent = card.term; + fcBackTranslation.textContent = card.translation; + fcBackPos.textContent = card.part_of_speech || ''; + fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none'; + } else { + fcFrontDirBadge.textContent = 'RU ➔ EN'; + fcFrontMain.textContent = card.translation; + fcFrontPos.textContent = card.part_of_speech || ''; + fcFrontPhoneticWrap.style.display = 'none'; + fcHintBtn.style.display = 'none'; + + fcBackTerm.textContent = card.term; + fcBackTranslation.textContent = card.translation; + fcBackPos.textContent = card.part_of_speech || ''; + } + + fcBackDef.textContent = card.definition_en || ''; + fcBackDefBox.style.display = card.definition_en ? 'block' : 'none'; + fcBackContext.textContent = card.context_sentence ? `“${card.context_sentence}”` : ''; + fcBackContextBox.style.display = card.context_sentence ? 'block' : 'none'; + fcBackNotes.textContent = card.notes || ''; + fcBackNotesBox.style.display = card.notes ? 'block' : 'none'; + + fcFrontAudioBtn.style.display = 'none'; + fcBackAudioBtn.style.display = 'none'; + } + + function flipFlashcard() { + if (srsIsFlipped) return; + srsIsFlipped = true; + flashcardInner.classList.add('is-flipped'); + fcRatingBar.classList.remove('hidden'); + fcFlipBtnWrap.style.display = 'none'; + } + + flashcardFlipWrap.addEventListener('click', flipFlashcard); + fcFlipBtn.addEventListener('click', flipFlashcard); + fcHintBtn.addEventListener('click', (e) => { + e.stopPropagation(); + fcHintBox.classList.toggle('hidden'); + }); + + document.querySelectorAll('.btn-rate').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const rating = parseInt(btn.dataset.rating, 10); + await submitCardReview(rating); + }); + }); + + async function submitCardReview(rating) { + const card = srsQueue[srsCurrentIndex]; + if (!card) return; + + srsSessionRatings.push(rating); + + try { + await api.srs.submitReview(card.id, rating); + } catch (e) { + console.error('Review submission error:', e); + } + + srsCurrentIndex++; + renderSrsCurrentCard(); + } + + // ----------------------------------------------------------------------- + // Mode 2: Multiple Choice Quiz + // ----------------------------------------------------------------------- + function renderQuizMode(card) { + srsModeQuiz.classList.remove('hidden'); + quizAnswered = false; + quizFeedbackBox.classList.add('hidden'); + quizDetailsBox.classList.add('hidden'); + quizNextBar.classList.add('hidden'); + + quizPos.textContent = card.part_of_speech || ''; + quizTermDisplay.textContent = card.term; + quizPhoneticDisplay.textContent = card.phonetic || ''; + quizPhoneticDisplay.style.display = card.phonetic ? 'block' : 'none'; + + if (card.context_sentence) { + quizContextHint.textContent = `“${card.context_sentence}”`; + quizContextHint.classList.remove('hidden'); + } else { + quizContextHint.classList.add('hidden'); + } + + quizAudioBtn.classList.add('hidden'); + + const correctText = card.translation; + const all = getAllCards(); + const candidates = all + .filter(c => c.id !== card.id && c.translation && c.translation !== correctText) + .map(c => c.translation); + + shuffleArray(candidates); + const options = [correctText, ...candidates.slice(0, 3)]; + while (options.length < 4) { + options.push(`Вариант ${options.length + 1}`); + } + shuffleArray(options); + quizCurrentOptions = options; + + quizOptionsGrid.innerHTML = ''; + options.forEach((optText, idx) => { + const btn = document.createElement('button'); + btn.className = 'quiz-option-btn'; + btn.innerHTML = ` + ${idx + 1} + ${escapeHtml(optText)} + `; + btn.onclick = () => handleQuizSelection(optText, correctText, card, btn); + quizOptionsGrid.appendChild(btn); + }); + } + + async function handleQuizSelection(selectedText, correctText, card, clickedBtn) { + if (quizAnswered) return; + quizAnswered = true; + + const isCorrect = selectedText === correctText; + const allOptionBtns = quizOptionsGrid.querySelectorAll('.quiz-option-btn'); + + allOptionBtns.forEach(btn => { + btn.disabled = true; + const text = btn.querySelector('.quiz-opt-text').textContent; + if (text === correctText) { + btn.classList.add('opt-correct'); + } else if (btn === clickedBtn && !isCorrect) { + btn.classList.add('opt-wrong'); + } + }); + + if (card.audio_url) playAudio(card.audio_url); + + if (isCorrect) { + quizFeedbackBox.className = 'trainer-feedback-box feedback-correct'; + quizFeedbackBox.innerHTML = '🎉 Отлично! Правильный ответ.'; + srsSessionRatings.push(4); + api.srs.submitReview(card.id, 4); + } else { + quizFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; + quizFeedbackBox.innerHTML = `❌ Ошибка! Правильно: ${escapeHtml(correctText)}`; + srsSessionRatings.push(1); + api.srs.submitReview(card.id, 1); + } + quizFeedbackBox.classList.remove('hidden'); + + if (card.definition_en || card.context_sentence) { + quizDetailsBox.innerHTML = ` + ${card.definition_en ? `

Толкование: ${escapeHtml(card.definition_en)}

` : ''} + ${card.context_sentence ? `

Контекст: “${escapeHtml(card.context_sentence)}”

` : ''} + `; + quizDetailsBox.classList.remove('hidden'); + } + + quizNextBar.classList.remove('hidden'); + quizNextBtn.focus(); + } + + quizNextBtn.addEventListener('click', () => { + srsCurrentIndex++; + renderSrsCurrentCard(); + }); + + // ----------------------------------------------------------------------- + // Mode 3: Active Typing + // ----------------------------------------------------------------------- + function renderTypingMode(card) { + srsModeTyping.classList.remove('hidden'); + typingFeedbackBox.classList.add('hidden'); + typingDetailsBox.classList.add('hidden'); + typingRatingBar.classList.add('hidden'); + + typingPos.textContent = card.part_of_speech || ''; + typingPromptRu.textContent = card.translation; + if (card.context_sentence) { + typingContextHint.textContent = `Контекст: “${card.context_sentence}”`; + typingContextHint.classList.remove('hidden'); + } else { + typingContextHint.classList.add('hidden'); + } + + typingInput.value = ''; + typingInput.disabled = false; + typingInput.focus(); + } + + typingForm.addEventListener('submit', (e) => { + e.preventDefault(); + const card = srsQueue[srsCurrentIndex]; + if (!card) return; + + const userInput = typingInput.value.trim().toLowerCase(); + const target = card.term.trim().toLowerCase(); + const isMatch = userInput === target; + + if (card.audio_url) playAudio(card.audio_url); + typingInput.disabled = true; + + if (isMatch) { + typingFeedbackBox.className = 'trainer-feedback-box feedback-correct'; + typingFeedbackBox.innerHTML = `✅ Верно! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; + } else { + typingFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; + typingFeedbackBox.innerHTML = `❌ Не совсем: правильное написание — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; + } + typingFeedbackBox.classList.remove('hidden'); + + renderDynamicRatingBar(typingRatingBar, isMatch ? 3 : 1); + }); + + // ----------------------------------------------------------------------- + // Mode 4: Cloze Deletion + // ----------------------------------------------------------------------- + function renderClozeMode(card) { + srsModeCloze.classList.remove('hidden'); + clozeFeedbackBox.classList.add('hidden'); + clozeDetailsBox.classList.add('hidden'); + clozeRatingBar.classList.add('hidden'); + + clozePos.textContent = card.part_of_speech || ''; + clozeTargetTranslation.textContent = card.translation; + + const context = card.context_sentence || `The word is ${card.term}.`; + const re = new RegExp(escapeRegex(card.term), 'gi'); + const gapText = context.replace(re, '_____'); + clozeSentenceDisplay.innerHTML = gapText; + + clozeInput.value = ''; + clozeInput.disabled = false; + clozeInput.focus(); + } + + clozeForm.addEventListener('submit', (e) => { + e.preventDefault(); + const card = srsQueue[srsCurrentIndex]; + if (!card) return; + + const userInput = clozeInput.value.trim().toLowerCase(); + const target = card.term.trim().toLowerCase(); + const isMatch = userInput === target; + + if (card.audio_url) playAudio(card.audio_url); + clozeInput.disabled = true; + + if (isMatch) { + clozeFeedbackBox.className = 'trainer-feedback-box feedback-correct'; + clozeFeedbackBox.innerHTML = `✅ Верно! целевое слово: ${escapeHtml(card.term)}`; + } else { + clozeFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; + clozeFeedbackBox.innerHTML = `❌ Правильный ответ: ${escapeHtml(card.term)}`; + } + clozeFeedbackBox.classList.remove('hidden'); + + renderDynamicRatingBar(clozeRatingBar, isMatch ? 3 : 1); + }); + + // ----------------------------------------------------------------------- + // Mode 5: Dictation + // ----------------------------------------------------------------------- + function renderDictationMode(card) { + srsModeDictation.classList.remove('hidden'); + dictationFeedbackBox.classList.add('hidden'); + dictationDetailsBox.classList.add('hidden'); + dictationRatingBar.classList.add('hidden'); + dictationHintText.classList.add('hidden'); + dictationHintText.textContent = card.translation; + + dictationInput.value = ''; + dictationInput.disabled = false; + + const play = () => { if (card.audio_url) playAudio(card.audio_url); }; + dictationBigPlayBtn.onclick = play; + dictationReplayBtn.onclick = play; + play(); + + dictationInput.focus(); + } + + dictationToggleHintBtn.addEventListener('click', () => { + dictationHintText.classList.toggle('hidden'); + }); + + dictationForm.addEventListener('submit', (e) => { + e.preventDefault(); + const card = srsQueue[srsCurrentIndex]; + if (!card) return; + + const userInput = dictationInput.value.trim().toLowerCase(); + const target = card.term.trim().toLowerCase(); + const isMatch = userInput === target; + + dictationInput.disabled = true; + + if (isMatch) { + dictationFeedbackBox.className = 'trainer-feedback-box feedback-correct'; + dictationFeedbackBox.innerHTML = `✅ Идеально! ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''} — ${escapeHtml(card.translation)}`; + } else { + dictationFeedbackBox.className = 'trainer-feedback-box feedback-wrong'; + dictationFeedbackBox.innerHTML = `❌ Ошибка спеллинга: правильное слово — ${escapeHtml(card.term)} ${card.phonetic ? `${escapeHtml(card.phonetic)}` : ''}`; + } + dictationFeedbackBox.classList.remove('hidden'); + + renderDynamicRatingBar(dictationRatingBar, isMatch ? 3 : 1); + }); + + function renderDynamicRatingBar(container, defaultRating) { + container.innerHTML = ` + + + + + `; + container.classList.remove('hidden'); + + container.querySelectorAll('.btn-rate').forEach(btn => { + btn.onclick = async () => { + const r = parseInt(btn.dataset.rating, 10); + await submitCardReview(r); + }; + }); + } + + function showSrsSummary() { + hideAllSrsModeContainers(); + srsSummary.classList.remove('hidden'); + srsProgressFill.style.width = '100%'; + srsCounter.textContent = 'Тренировка завершена!'; + + const total = srsSessionRatings.length; + sumCount.textContent = total; + const goodCount = srsSessionRatings.filter(r => r >= 3).length; + const retention = total > 0 ? Math.round((goodCount / total) * 100) : 100; + sumRetention.textContent = `${retention}%`; + } + + // Hotkeys handler + document.addEventListener('keydown', (e) => { + if (!srsView.classList.contains('hidden')) { + if (srsCurrentMode === 'flashcards') { + if (e.code === 'Space' && !srsIsFlipped) { + e.preventDefault(); + flipFlashcard(); + } else if (srsIsFlipped && ['1', '2', '3', '4'].includes(e.key)) { + e.preventDefault(); + submitCardReview(parseInt(e.key, 10)); + } + } else if (srsCurrentMode === 'quiz' && !quizAnswered) { + if (['1', '2', '3', '4'].includes(e.key)) { + const idx = parseInt(e.key, 10) - 1; + const btns = quizOptionsGrid.querySelectorAll('.quiz-option-btn'); + if (btns[idx]) { + e.preventDefault(); + btns[idx].click(); + } + } + } else if (srsCurrentMode === 'quiz' && quizAnswered) { + if (e.key === 'Enter' || e.code === 'Space') { + e.preventDefault(); + quizNextBtn.click(); + } + } + } + }); + + // ----------------------------------------------------------------------- + // Analytics Dashboard & Heatmap Modal + // ----------------------------------------------------------------------- + if (statsOpenBtn) statsOpenBtn.addEventListener('click', openStatsDashboard); + if (mobileStatsBtn) mobileStatsBtn.addEventListener('click', openStatsDashboard); + closeStatsModalBtn.addEventListener('click', () => closeModal(statsModal)); + closeStatsBtn.addEventListener('click', () => closeModal(statsModal)); + + async function openStatsDashboard() { + openModal(statsModal); + await Promise.all([loadStatsKpis(), loadActivityHeatmap()]); + } + + async function loadStatsKpis() { + try { + const s = await api.stats.getSrsStats(); + if (s) { + kpiCurrentStreak.textContent = `🔥 ${s.streak_days}`; + kpiLongestStreak.textContent = `Рекорд: ${s.longest_streak} дн.`; + kpiRetentionRate.textContent = `${s.retention_rate}%`; + kpiTotalReviews.textContent = `Всего повторений: ${s.total_reviews}`; + kpiTotalWords.textContent = s.total_cards; + kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`; + + if (s.cefr) { + kpiCefrLevel.textContent = s.cefr.level; + kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`; + } else { + const count = s.total_cards; + let lvl = 'A1'; + let desc = 'Базовый словарный запас'; + if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; } + else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; } + else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; } + else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; } + kpiCefrLevel.textContent = lvl; + kpiCefrDesc.textContent = desc; + } + + cntMastered.textContent = s.mastered; + cntReview.textContent = s.review; + cntLearning.textContent = s.learning; + cntNew.textContent = s.new_cards; + cntLeeches.textContent = s.leech_count; + + const total = s.total_cards || 1; + progMastered.style.width = `${(s.mastered / total) * 100}%`; + progReview.style.width = `${(s.review / total) * 100}%`; + progLearning.style.width = `${(s.learning / total) * 100}%`; + progNew.style.width = `${(s.new_cards / total) * 100}%`; + } + } catch (e) { + console.error('Stats load error:', e); + } + } + + async function loadActivityHeatmap() { + try { + const data = await api.stats.getHeatmap(180); + renderHeatmap(data.days); + } catch (e) { + console.error('Heatmap load error:', e); + } + } + + function renderHeatmap(days) { + heatmapGrid.innerHTML = ''; + if (!days || days.length === 0) return; + + days.forEach(d => { + const cell = document.createElement('div'); + cell.className = `heatmap-cell lvl-${d.level}`; + cell.title = `${d.date}: ${d.count} повторений / тестов`; + heatmapGrid.appendChild(cell); + }); + } + + initSrs.startSession = startSrsTrainingSession; + initSrs.openStats = openStatsDashboard; +} + +export function startSrsTrainingSession(forceAll = false, tagFilter = null) { + if (initSrs.startSession) { + return initSrs.startSession(forceAll, tagFilter); + } +} + +export function openStatsDashboard() { + if (initSrs.openStats) { + return initSrs.openStats(); + } +} diff --git a/app/static/js/modules/ui.js b/app/static/js/modules/ui.js new file mode 100644 index 0000000..7af4d54 --- /dev/null +++ b/app/static/js/modules/ui.js @@ -0,0 +1,162 @@ +// ======================================================================= +// Notes Application - UI & Utilities Module (Native ES Module) +// ======================================================================= + +export function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function slugify(text) { + if (!text) return 'book'; + return text.toString().toLowerCase().trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function shuffleArray(array) { + const arr = [...array]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// ----------------------------------------------------------------------- +// Toast Notifications +// ----------------------------------------------------------------------- +export function showToast(message, type = 'info') { + let toastContainer = document.getElementById('notes-toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'notes-toast-container'; + toastContainer.style.cssText = ` + position: fixed; + bottom: 24px; + right: 24px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + `; + document.body.appendChild(toastContainer); + } + + const toast = document.createElement('div'); + const colors = { + success: '#10b981', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6' + }; + const bg = colors[type] || colors.info; + + toast.style.cssText = ` + background: ${bg}; + color: #ffffff; + padding: 10px 18px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + box-shadow: 0 4px 14px rgba(0,0,0,0.25); + opacity: 0; + transform: translateY(12px); + transition: all 0.25s ease; + pointer-events: auto; + max-width: 340px; + `; + toast.textContent = message; + toastContainer.appendChild(toast); + + requestAnimationFrame(() => { + toast.style.opacity = '1'; + toast.style.transform = 'translateY(0)'; + }); + + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateY(12px)'; + setTimeout(() => toast.remove(), 250); + }, 3500); +} + +// ----------------------------------------------------------------------- +// Theme Management +// ----------------------------------------------------------------------- +export function initTheme() { + const themeToggle = document.getElementById('theme-toggle'); + const mobileThemeBtn = document.getElementById('mobile-theme-btn'); + const savedTheme = localStorage.getItem('notes_theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + + function toggle() { + const current = document.documentElement.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + localStorage.setItem('notes_theme', next); + } + + if (themeToggle) { + themeToggle.addEventListener('click', toggle); + } + if (mobileThemeBtn) { + mobileThemeBtn.addEventListener('click', () => { + toggle(); + showToast(`Тема переключена`, 'info'); + }); + } +} + +// ----------------------------------------------------------------------- +// Modal Open / Close Helpers +// ----------------------------------------------------------------------- +export function openModal(modalElem) { + if (!modalElem) return; + modalElem.classList.remove('hidden'); +} + +export function closeModal(modalElem) { + if (!modalElem) return; + modalElem.classList.add('hidden'); +} + +// ----------------------------------------------------------------------- +// Header Stat Counters +// ----------------------------------------------------------------------- +export function updateHeaderStats(total = 0, today = 0, srsCount = 0) { + const statTotal = document.getElementById('stat-total'); + const statToday = document.getElementById('stat-today'); + const srsBadge = document.getElementById('srs-badge'); + + if (statTotal) statTotal.textContent = total; + if (statToday) statToday.textContent = today; + if (srsBadge) { + srsBadge.textContent = srsCount; + srsBadge.style.display = srsCount > 0 ? 'inline-block' : 'none'; + } +} + +// ----------------------------------------------------------------------- +// Audio Player Helper (Safe stub / audio stream) +// ----------------------------------------------------------------------- +export function playAudio(url) { + if (!url) return; + try { + const audio = new Audio(url); + audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e)); + } catch (e) { + console.warn('Audio play error:', e); + } +} diff --git a/app/static/sw.js b/app/static/sw.js index 4faeda8..113ee52 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -1,13 +1,18 @@ // Service Worker for Notes English PWA (Stage 2) // Strategy: Cache-first for static assets, Network-first for API calls. -const CACHE_NAME = 'notes-v2.2'; +const CACHE_NAME = 'notes-v5.0'; const OFFLINE_URL = '/'; const PRECACHE_URLS = [ '/', '/static/css/style.css', '/static/js/app.js', + '/static/js/modules/api.js', + '/static/js/modules/ui.js', + '/static/js/modules/dictionary.js', + '/static/js/modules/srs.js', + '/static/js/modules/reader.js', '/manifest.json', ];