// Notes Application Frontend Logic - Complete SRS, Heatmap, Quiz, and Analytics 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 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'); // 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 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'); userDisplayName.textContent = currentUser ? currentUser.username : 'user'; } 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') { 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 () => { 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(); srsBadge.textContent = stats.due_cards || 0; } } 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'); } 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 = `
Толкование: ${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 = `