// 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 readerFontDecBtn = document.getElementById('reader-font-dec-btn'); const readerFontIncBtn = document.getElementById('reader-font-inc-btn'); 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 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 let currentBook = null; let currentChapterIndex = 0; let readerFontSize = 19; 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; } // ----------------------------------------------------------------------- // 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(); 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(); 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'); await fetch('/api/logout', { method: 'POST' }); currentUser = null; 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 (5 Modes + Smart Mix) // ----------------------------------------------------------------------- srsTrainerBtn.addEventListener('click', () => { openSrsView(); }); srsExitBtn.addEventListener('click', () => { closeSrsView(); }); navBrandHome.addEventListener('click', () => { closeSrsView(); }); 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() { srsView.classList.add('hidden'); if (readerView) readerView.classList.add('hidden'); dictionaryView.classList.remove('hidden'); updateMobileActiveTab('dict'); loadInitialData(); } function openSrsView(tagFilter = null) { dictionaryView.classList.add('hidden'); if (readerView) readerView.classList.add('hidden'); srsView.classList.remove('hidden'); updateMobileActiveTab('srs'); startSrsTrainingSession(false, tagFilter); } function closeSrsView() { srsView.classList.add('hidden'); if (readerView) readerView.classList.add('hidden'); dictionaryView.classList.remove('hidden'); updateMobileActiveTab('dict'); loadInitialData(); } function openReaderView() { dictionaryView.classList.add('hidden'); srsView.classList.add('hidden'); if (readerView) readerView.classList.remove('hidden'); updateMobileActiveTab('reader'); loadBookshelf(); } 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', () => openDictionaryView()); if (tabSrs) tabSrs.addEventListener('click', () => openSrsView()); if (tabReader) tabReader.addEventListener('click', () => openReaderView()); 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', () => openDictionaryView()); if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView()); 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; 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 = b.chapters_count || 1; const currentCh = (b.current_chapter || 0) + 1; const pct = Math.min(100, Math.round((currentCh / totalCh) * 100)); card.innerHTML = `
${b.source_type === 'epub' ? '📕' : '📄'}

${escapeHtml(b.title)}

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

${escapeHtml(b.source_type)} ${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}
Глава ${currentCh} из ${totalCh} ${pct}%
`; card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id)); card.querySelector('.train-book-btn').addEventListener('click', () => { const bookTag = slugify(b.title); openSrsView(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) { try { const resp = await fetch(`/api/books/${bookId}`); if (!resp.ok) return alert('Не удалось открыть книгу'); currentBook = await resp.json(); currentChapterIndex = currentBook.current_chapter || 0; readerBookTitle.textContent = currentBook.title; readerBookAuthor.textContent = currentBook.author || ''; // Populate chapter selector readerChapterSelect.innerHTML = ''; const chapters = currentBook.chapters || []; chapters.forEach((ch, idx) => { const opt = document.createElement('option'); opt.value = idx; opt.textContent = ch.title || `Глава ${idx + 1}`; readerChapterSelect.appendChild(opt); }); bookshelfContainer.classList.add('hidden'); readerInterface.classList.remove('hidden'); renderChapter(currentChapterIndex); } catch (e) { console.error('Error opening book:', e); } } function renderChapter(index) { if (!currentBook || !currentBook.chapters) return; currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1)); readerChapterSelect.value = currentChapterIndex; readerPrevChBtn.disabled = currentChapterIndex <= 0; readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1; const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' }; readerContentBody.innerHTML = ''; if (chapter.title) { const h = document.createElement('h2'); h.textContent = chapter.title; readerContentBody.appendChild(h); } // Map existing user vocabulary for status marking const knownWordsMap = new Map(); allCards.forEach(c => { if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c); }); // Parse content into structured blocks (code, headings, lists, quotes, paragraphs) const blocks = parseContentBlocks(chapter.content); blocks.forEach(block => { if (block.type === 'code') { const codeBlock = document.createElement('div'); codeBlock.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); codeBlock.appendChild(header); codeBlock.appendChild(pre); readerContentBody.appendChild(codeBlock); } else if (block.type === 'table') { const wrap = document.createElement('div'); wrap.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); } wrap.appendChild(table); readerContentBody.appendChild(wrap); } else if (block.type === 'heading') { const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`; const h = document.createElement(tag); if (block.align) h.style.textAlign = block.align; renderInteractiveSpans(h, block.text, block.text, knownWordsMap); readerContentBody.appendChild(h); } else if (block.type === 'blockquote') { const bq = document.createElement('blockquote'); renderInteractiveSpans(bq, block.text, block.text, knownWordsMap); readerContentBody.appendChild(bq); } else if (block.type === 'ul' || block.type === 'ol') { const listEl = document.createElement(block.type); block.items.forEach(itemText => { const li = document.createElement('li'); renderInteractiveSpans(li, itemText, itemText, knownWordsMap); listEl.appendChild(li); }); readerContentBody.appendChild(listEl); } else if (block.type === 'hr') { readerContentBody.appendChild(document.createElement('hr')); } else { // paragraph const p = document.createElement('p'); if (block.align) { p.style.textAlign = block.align; p.classList.add('text-center'); } renderInteractiveSpans(p, block.text, block.text, knownWordsMap); readerContentBody.appendChild(p); } }); if (readerScrollArea) readerScrollArea.scrollTop = 0; // Save progress to server fetch(`/api/books/${currentBook.id}/progress`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: 0 }), }).catch(err => console.log('Progress save error:', err)); } // 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; 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 if (readerChapterSelect) { readerChapterSelect.addEventListener('change', () => { renderChapter(parseInt(readerChapterSelect.value, 10)); }); } if (readerPrevChBtn) { readerPrevChBtn.addEventListener('click', () => { if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1); }); } if (readerNextChBtn) { readerNextChBtn.addEventListener('click', () => { if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) { renderChapter(currentChapterIndex + 1); } }); } if (readerBackBtn) { readerBackBtn.addEventListener('click', () => { readerInterface.classList.add('hidden'); bookshelfContainer.classList.remove('hidden'); loadBookshelf(); }); } if (readerFontIncBtn) { readerFontIncBtn.addEventListener('click', () => { if (readerFontSize < 28) { readerFontSize += 2; readerContentBody.style.fontSize = `${readerFontSize}px`; } }); } if (readerFontDecBtn) { readerFontDecBtn.addEventListener('click', () => { if (readerFontSize > 14) { readerFontSize -= 2; readerContentBody.style.fontSize = `${readerFontSize}px`; } }); } 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); openSrsView(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'); await loadBookshelf(); openBook(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 = ''; await loadBookshelf(); openBook(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(); });