// 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); }; 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)); } // 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'); 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 return; } // ----------------------------------------------------------------------- // Immediate Pre-flight Routing (Zero-flicker view restoration on F5) // ----------------------------------------------------------------------- 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) {} } 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) { hash = '#dictionary'; } } 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); } } else { openDictionaryView(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 || ''; } 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'); }); // 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), }); 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'); } }); // ======================================================================= // 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, ''); } 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'); 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); } } function renderBookshelf(books) { 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' ? '📕' : '📄'}

${escapeHtml(b.title)}

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

${escapeHtml(b.source_type)} ${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 fetch(`/api/books/${b.id}`, { method: 'DELETE' }); loadBookshelf(); } }); bookshelfGrid.appendChild(card); }); } 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); } } } 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 resp = await fetch(`/api/books/${bookId}`); if (!resp.ok) { alert('Не удалось открыть книгу'); window.location.hash = '#reader'; 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)); 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); }); currentBook.chapters.forEach((_, idx) => { loadedChaptersSet.add(idx); const chFrag = buildChapterDOM(idx, knownWordsMap, idx > 0); readerContentBody.appendChild(chFrag); }); const finalFooter = buildChapterFooterDOM(currentBook.chapters.length - 1); if (finalFooter) { readerContentBody.appendChild(finalFooter); } 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 const parser = new DOMParser(); const doc = parser.parseFromString(`${processed}`, 'text/html'); // 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.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'); } span.addEventListener('click', (e) => { e.stopPropagation(); handleWordClick(token, contextText || raw, span); }); 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(doc.body.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 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'); } await fetchCards(); await fetchTags(); } 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; } } 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 (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; 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 chapters mode if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) { const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight); if (distanceToBottom < 450) { 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; }, 300); } } } 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 (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 { const errData = await resp.json(); alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`); } } 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, '''); } 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 checkAuth(); });