Files
notes-qpid-pro/app/static/js/app.js
T

3547 lines
149 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Notes Application Frontend Logic - Complete SRS, Heatmap, Quiz, and Analytics
// Persistent session: auto-inject Authorization header if token exists in localStorage
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
const token = localStorage.getItem('notes_token');
if (token && typeof url === 'string' && url.startsWith('/api/')) {
options.headers = options.headers || {};
if (options.headers instanceof Headers) {
if (!options.headers.has('Authorization')) {
options.headers.set('Authorization', `Bearer ${token}`);
}
} else if (Array.isArray(options.headers)) {
if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) {
options.headers.push(['Authorization', `Bearer ${token}`]);
}
} else {
if (!options.headers['Authorization']) {
options.headers['Authorization'] = `Bearer ${token}`;
}
}
}
return originalFetch.call(this, url, options);
};
document.addEventListener('DOMContentLoaded', () => {
// Register PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => console.log('Notes PWA ServiceWorker active:', reg.scope))
.catch(err => console.log('Notes ServiceWorker failed:', err));
}
// App State
let currentUser = null;
let currentLookupData = null;
let allCards = [];
let selectedTag = '';
let selectedCollection = '';
let searchQuery = '';
let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
// Batch Selection State
let isBatchMode = false;
let selectedIds = new Set();
let exportScope = 'all';
// SRS State
let srsQueue = [];
let srsCurrentIndex = 0;
let srsCurrentMode = 'flashcards'; // 'flashcards' | 'quiz' | 'typing' | 'cloze' | 'dictation'
let srsDirection = 'en-ru';
let srsSmartMix = true;
let srsSessionRatings = [];
let srsIsFlipped = false;
let quizCurrentOptions = [];
let quizAnswered = false;
// Elements - Auth
const loginOverlay = document.getElementById('login-overlay');
const loginForm = document.getElementById('login-form');
const loginUsername = document.getElementById('login-username');
const loginPassword = document.getElementById('login-password');
const loginError = document.getElementById('login-error');
const logoutBtn = document.getElementById('logout-btn');
const userDisplayName = document.getElementById('user-display-name');
// Elements - Views & Navigation
const appShell = document.getElementById('app-shell');
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const navReaderBtn = document.getElementById('nav-reader-btn');
const navBrandHome = document.getElementById('nav-brand-home');
const themeToggle = document.getElementById('theme-toggle');
const statTotal = document.getElementById('stat-total');
const statToday = document.getElementById('stat-today');
const srsTrainerBtn = document.getElementById('srs-trainer-btn');
const srsBadge = document.getElementById('srs-badge');
const statsOpenBtn = document.getElementById('stats-open-btn');
const exportOpenBtn = document.getElementById('export-open-btn');
const changePwdOpenBtn = document.getElementById('change-pwd-open-btn');
// Elements - Reader & Bookshelf
const bookshelfContainer = document.getElementById('bookshelf-container');
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const bookshelfGrid = document.getElementById('bookshelf-grid');
const addTextBtn = document.getElementById('add-text-btn');
const bookFileInput = document.getElementById('book-file-input');
const addBookModal = document.getElementById('add-book-modal');
const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
const addBookForm = document.getElementById('add-book-form');
const bookTitleInput = document.getElementById('book-title-input');
const bookAuthorInput = document.getElementById('book-author-input');
const bookContentInput = document.getElementById('book-content-input');
const readerInterface = document.getElementById('reader-interface');
const readerBackBtn = document.getElementById('reader-back-btn');
const readerBookTitle = document.getElementById('reader-book-title');
const readerBookAuthor = document.getElementById('reader-book-author');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerSettingsBtn = document.getElementById('reader-settings-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn');
const settingFontDecBtn = document.getElementById('setting-font-dec-btn');
const settingFontIncBtn = document.getElementById('setting-font-inc-btn');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerFontFamilyGroup = document.getElementById('reader-font-family-group');
const readerLineHeightGroup = document.getElementById('reader-line-height-group');
const readerMarginsGroup = document.getElementById('reader-margins-group');
const readerThemeGroup = document.getElementById('reader-theme-group');
const readerAlignGroup = document.getElementById('reader-align-group');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterProgressBar = document.getElementById('reader-chapter-progress-bar');
const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill');
const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge');
const readerWordPopup = document.getElementById('reader-word-popup');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupInLibrary = document.getElementById('popup-in-library');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupLoading = document.getElementById('popup-loading');
const popupContent = document.getElementById('popup-content');
const popupTranslation = document.getElementById('popup-translation');
const popupContext = document.getElementById('popup-context');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
// Reader State & Appearance Settings
const DEFAULT_READER_SETTINGS = {
fontSize: 18,
fontFamily: 'serif',
lineHeight: 1.65,
marginWidth: 'normal',
theme: 'dark',
textAlign: 'left'
};
let readerSettings = { ...DEFAULT_READER_SETTINGS };
let currentBook = null;
let currentChapterIndex = 0;
let isHighlightEnabled = false;
let popupCurrentData = null;
// Elements - Search & Capture
const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
const translateBtn = document.getElementById('translate-btn');
const duplicateWarningBox = document.getElementById('duplicate-warning-box');
const dupTermLabel = document.getElementById('dup-term-label');
const dupTransLabel = document.getElementById('dup-trans-label');
const dupOpenBtn = document.getElementById('dup-open-btn');
const lookupCard = document.getElementById('lookup-card');
const resTerm = document.getElementById('res-term');
const resPhonetic = document.getElementById('res-phonetic');
const resInLibrary = document.getElementById('res-in-library');
const audioPlayBtn = document.getElementById('audio-play-btn');
const resPos = document.getElementById('res-pos');
const resDefinitionWrap = document.getElementById('res-definition-wrap');
const resDefinition = document.getElementById('res-definition');
const editTranslation = document.getElementById('edit-translation');
const editContext = document.getElementById('edit-context');
const editCollection = document.getElementById('edit-collection');
const collectionsDatalist = document.getElementById('collections-datalist');
const editTags = document.getElementById('edit-tags');
const editNotes = document.getElementById('edit-notes');
const saveCardBtn = document.getElementById('save-card-btn');
// Elements - Vocabulary List
const filterInput = document.getElementById('filter-input');
const cardsCountLabel = document.getElementById('cards-count-label');
const collectionsFilterBar = document.getElementById('collections-filter-bar');
const newCollectionBtn = document.getElementById('new-collection-btn');
const tagsFilterBar = document.getElementById('tags-filter-bar');
const cardsList = document.getElementById('cards-list');
const emptyState = document.getElementById('empty-state');
// Elements - Batch Action Bar
const batchToggleBtn = document.getElementById('batch-toggle-btn');
const batchActionBar = document.getElementById('batch-action-bar');
const batchSelectAll = document.getElementById('batch-select-all');
const batchCountLabel = document.getElementById('batch-count-label');
const batchMoveBtn = document.getElementById('batch-move-btn');
const batchTagBtn = document.getElementById('batch-tag-btn');
const batchDeleteBtn = document.getElementById('batch-delete-btn');
const batchCancelBtn = document.getElementById('batch-cancel-btn');
// Elements - SRS Trainer View
const srsExitBtn = document.getElementById('srs-exit-btn');
const srsTabs = document.querySelectorAll('.srs-tab');
const srsProgressFill = document.getElementById('srs-progress-fill');
const srsCounter = document.getElementById('srs-counter');
const srsDirBtn = document.getElementById('srs-dir-btn');
const srsDirLabel = document.getElementById('srs-dir-label');
const srsMixBtn = document.getElementById('srs-mix-btn');
const srsMixLabel = document.getElementById('srs-mix-label');
const srsLoading = document.getElementById('srs-loading');
const srsEmpty = document.getElementById('srs-empty');
const srsTrainAllBtn = document.getElementById('srs-train-all-btn');
const srsEmptyBackBtn = document.getElementById('srs-empty-back-btn');
// Mode 1: Flashcards
const srsModeFlashcards = document.getElementById('srs-mode-flashcards');
const flashcardFlipWrap = document.getElementById('flashcard-flip-wrap');
const flashcardInner = document.getElementById('flashcard-inner');
const fcFrontDirBadge = document.getElementById('fc-front-dir-badge');
const fcFrontPos = document.getElementById('fc-front-pos');
const fcFrontMain = document.getElementById('fc-front-main');
const fcFrontPhoneticWrap = document.getElementById('fc-front-phonetic-wrap');
const fcFrontPhonetic = document.getElementById('fc-front-phonetic');
const fcFrontAudioBtn = document.getElementById('fc-front-audio-btn');
const fcHintBtn = document.getElementById('fc-hint-btn');
const fcHintBox = document.getElementById('fc-hint-box');
const fcBackPos = document.getElementById('fc-back-pos');
const fcBackTerm = document.getElementById('fc-back-term');
const fcBackAudioBtn = document.getElementById('fc-back-audio-btn');
const fcBackTranslation = document.getElementById('fc-back-translation');
const fcBackDefBox = document.getElementById('fc-back-def-box');
const fcBackDef = document.getElementById('fc-back-def');
const fcBackContextBox = document.getElementById('fc-back-context-box');
const fcBackContext = document.getElementById('fc-back-context');
const fcBackNotesBox = document.getElementById('fc-back-notes-box');
const fcBackNotes = document.getElementById('fc-back-notes');
const fcFlipBtnWrap = document.getElementById('fc-flip-btn-wrap');
const fcFlipBtn = document.getElementById('fc-flip-btn');
const fcRatingBar = document.getElementById('fc-rating-bar');
// Mode 2: Multiple Choice Quiz
const srsModeQuiz = document.getElementById('srs-mode-quiz');
const quizPos = document.getElementById('quiz-pos');
const quizTermDisplay = document.getElementById('quiz-term-display');
const quizAudioBtn = document.getElementById('quiz-audio-btn');
const quizPhoneticDisplay = document.getElementById('quiz-phonetic-display');
const quizContextHint = document.getElementById('quiz-context-hint');
const quizOptionsGrid = document.getElementById('quiz-options-grid');
const quizFeedbackBox = document.getElementById('quiz-feedback-box');
const quizDetailsBox = document.getElementById('quiz-details-box');
const quizNextBar = document.getElementById('quiz-next-bar');
const quizNextBtn = document.getElementById('quiz-next-btn');
// Mode 3: Active Typing
const srsModeTyping = document.getElementById('srs-mode-typing');
const typingPos = document.getElementById('typing-pos');
const typingPromptRu = document.getElementById('typing-prompt-ru');
const typingContextHint = document.getElementById('typing-context-hint');
const typingForm = document.getElementById('typing-form');
const typingInput = document.getElementById('typing-input');
const typingFeedbackBox = document.getElementById('typing-feedback-box');
const typingDetailsBox = document.getElementById('typing-details-box');
const typingRatingBar = document.getElementById('typing-rating-bar');
// Mode 4: Cloze
const srsModeCloze = document.getElementById('srs-mode-cloze');
const clozePos = document.getElementById('cloze-pos');
const clozeSentenceDisplay = document.getElementById('cloze-sentence-display');
const clozeTargetTranslation = document.getElementById('cloze-target-translation');
const clozeForm = document.getElementById('cloze-form');
const clozeInput = document.getElementById('cloze-input');
const clozeFeedbackBox = document.getElementById('cloze-feedback-box');
const clozeDetailsBox = document.getElementById('cloze-details-box');
const clozeRatingBar = document.getElementById('cloze-rating-bar');
// Mode 5: Dictation
const srsModeDictation = document.getElementById('srs-mode-dictation');
const dictationReplayBtn = document.getElementById('dictation-replay-btn');
const dictationBigPlayBtn = document.getElementById('dictation-big-play-btn');
const dictationToggleHintBtn = document.getElementById('dictation-toggle-hint-btn');
const dictationHintText = document.getElementById('dictation-hint-text');
const dictationForm = document.getElementById('dictation-form');
const dictationInput = document.getElementById('dictation-input');
const dictationFeedbackBox = document.getElementById('dictation-feedback-box');
const dictationDetailsBox = document.getElementById('dictation-details-box');
const dictationRatingBar = document.getElementById('dictation-rating-bar');
// SRS Summary
const srsSummary = document.getElementById('srs-summary');
const sumCount = document.getElementById('sum-count');
const sumRetention = document.getElementById('sum-retention');
const sumStreak = document.getElementById('sum-streak');
const sumRatesBreakdown = document.getElementById('sum-rates-breakdown');
const sumRepeatBtn = document.getElementById('sum-repeat-btn');
const sumReturnBtn = document.getElementById('sum-return-btn');
// Modals - Stats / Heatmap
const statsModal = document.getElementById('stats-modal');
const closeStatsModalBtn = document.getElementById('close-stats-modal-btn');
const closeStatsBtn = document.getElementById('close-stats-btn');
const kpiCurrentStreak = document.getElementById('kpi-current-streak');
const kpiLongestStreak = document.getElementById('kpi-longest-streak');
const kpiRetentionRate = document.getElementById('kpi-retention-rate');
const kpiTotalReviews = document.getElementById('kpi-total-reviews');
const kpiTotalWords = document.getElementById('kpi-total-words');
const kpiTodayAdded = document.getElementById('kpi-today-added');
const kpiCefrLevel = document.getElementById('kpi-cefr-level');
const kpiCefrDesc = document.getElementById('kpi-cefr-desc');
const heatmapGrid = document.getElementById('heatmap-grid');
const progMastered = document.getElementById('prog-mastered');
const progReview = document.getElementById('prog-review');
const progLearning = document.getElementById('prog-learning');
const progNew = document.getElementById('prog-new');
const cntMastered = document.getElementById('cnt-mastered');
const cntReview = document.getElementById('cnt-review');
const cntLearning = document.getElementById('cnt-learning');
const cntNew = document.getElementById('cnt-new');
const cntLeeches = document.getElementById('cnt-leeches');
// Modals - Edit Card
const editModal = document.getElementById('edit-modal');
const closeModalBtn = document.getElementById('close-modal-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const modalSaveBtn = document.getElementById('modal-save-btn');
const modalCardId = document.getElementById('modal-card-id');
const modalTerm = document.getElementById('modal-term');
const modalTranslation = document.getElementById('modal-translation');
const modalContext = document.getElementById('modal-context');
const modalPhonetic = document.getElementById('modal-phonetic');
const modalPos = document.getElementById('modal-pos');
const modalDef = document.getElementById('modal-def');
const modalCollection = document.getElementById('modal-collection');
const modalTags = document.getElementById('modal-tags');
const modalNotes = document.getElementById('modal-notes');
// Modals - Export
const exportModal = document.getElementById('export-modal');
const closeExportModalBtn = document.getElementById('close-export-modal-btn');
const exportCancelBtn = document.getElementById('export-cancel-btn');
// Modals - Move Collection & New Collection
const moveColModal = document.getElementById('move-col-modal');
const closeMoveModalBtn = document.getElementById('close-move-modal-btn');
const moveColCancelBtn = document.getElementById('move-col-cancel-btn');
const moveColConfirmBtn = document.getElementById('move-col-confirm-btn');
const moveColInput = document.getElementById('move-col-input');
const moveColQuickList = document.getElementById('move-col-quick-list');
const newColModal = document.getElementById('new-col-modal');
const closeNewColModalBtn = document.getElementById('close-new-col-modal-btn');
const newColCancelBtn = document.getElementById('new-col-cancel-btn');
const newColSaveBtn = document.getElementById('new-col-save-btn');
const newColNameInput = document.getElementById('new-col-name-input');
// Modals - Change Password
const changePasswordModal = document.getElementById('change-password-modal');
const closeChangePwdBtn = document.getElementById('close-change-pwd-btn');
const changePwdCancelBtn = document.getElementById('change-pwd-cancel-btn');
const changePasswordForm = document.getElementById('change-password-form');
const currentPasswordInput = document.getElementById('current-password-input');
const newPasswordInput = document.getElementById('new-password-input');
const confirmPasswordInput = document.getElementById('confirm-password-input');
const changePwdError = document.getElementById('change-pwd-error');
const changePwdSuccess = document.getElementById('change-pwd-success');
// Audio player
const globalAudioPlayer = document.getElementById('global-audio-player');
// -----------------------------------------------------------------------
// Theme Management
// -----------------------------------------------------------------------
const savedTheme = localStorage.getItem('notes_theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('notes_theme', next);
});
// -----------------------------------------------------------------------
// Audio Player Helper (Disabled)
// -----------------------------------------------------------------------
function playAudio(url) {
// Audio playback disabled
return;
}
// -----------------------------------------------------------------------
// Immediate Pre-flight Routing (Zero-flicker view restoration on F5)
// -----------------------------------------------------------------------
function applyInitialViewPreflight() {
let hash = window.location.hash || '';
if (!hash || hash === '#') {
try {
const lastView = localStorage.getItem('notes_active_view');
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastView === 'reader-view') {
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
} else if (lastView === 'srs-view') {
hash = '#srs';
}
} catch (e) {}
}
if (hash.startsWith('#reader') || hash === '#bookshelf') {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
updateMobileActiveTab('reader');
if (hash.includes('/book/')) {
if (bookshelfContainer) bookshelfContainer.classList.add('hidden');
if (readerInterface) readerInterface.classList.remove('hidden');
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
}
} else if (hash.startsWith('#srs')) {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
}
}
applyInitialViewPreflight();
// -----------------------------------------------------------------------
// Session & Auth Check
// -----------------------------------------------------------------------
async function checkAuth() {
try {
const resp = await fetch('/api/me');
if (resp.ok) {
const data = await resp.json();
currentUser = data.user;
showApp();
await handleRouting();
loadInitialData();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
function showLogin() {
loginOverlay.classList.remove('hidden');
if (appShell) appShell.classList.add('hidden');
loginUsername.focus();
}
function showApp() {
loginOverlay.classList.add('hidden');
if (appShell) appShell.classList.remove('hidden');
const name = currentUser ? currentUser.username : 'user';
userDisplayName.textContent = name;
const mobileUserName = document.getElementById('mobile-user-name');
if (mobileUserName) mobileUserName.textContent = name;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginError.classList.add('hidden');
try {
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: loginUsername.value.trim(),
password: loginPassword.value,
}),
});
const data = await resp.json();
if (resp.ok && data.status === 'ok') {
if (data.token) {
localStorage.setItem('notes_token', data.token);
}
currentUser = data.user;
showApp();
await handleRouting();
loadInitialData();
} else {
loginError.textContent = data.detail || 'Неверный логин или пароль';
loginError.classList.remove('hidden');
}
} catch (err) {
loginError.textContent = 'Ошибка сетевого соединения';
loginError.classList.remove('hidden');
}
});
logoutBtn.addEventListener('click', async () => {
localStorage.removeItem('notes_token');
localStorage.removeItem('notes_active_book_id');
try { await fetch('/api/logout', { method: 'POST' }); } catch (e) {}
currentUser = null;
window.location.hash = '';
window.location.reload();
});
// -----------------------------------------------------------------------
// Data Loading: Cards, Collections, Tags, Stats
// -----------------------------------------------------------------------
async function loadInitialData() {
await Promise.all([
fetchCards(),
fetchCollections(),
fetchTags(),
fetchStats(),
fetchSrsStats(),
]);
}
async function fetchCards() {
try {
let url = `/api/cards?limit=500`;
if (selectedCollection) url += `&collection=${encodeURIComponent(selectedCollection)}`;
if (selectedTag) url += `&tag=${encodeURIComponent(selectedTag)}`;
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
const resp = await fetch(url);
if (resp.ok) {
const data = await resp.json();
allCards = data.cards || [];
renderCardsList();
}
} catch (e) {
console.error('Error fetching cards:', e);
}
}
async function fetchCollections() {
try {
const resp = await fetch('/api/collections');
if (resp.ok) {
const cols = await resp.json();
collectionsList = cols.map(c => c.name);
renderCollectionsFilter(cols);
populateDatalists();
}
} catch (e) {
console.error('Error fetching collections:', e);
}
}
async function fetchTags() {
try {
const resp = await fetch('/api/tags');
if (resp.ok) {
const tags = await resp.json();
renderTagsFilter(tags);
}
} catch (e) {
console.error('Error fetching tags:', e);
}
}
async function fetchStats() {
try {
const resp = await fetch('/api/stats');
if (resp.ok) {
const data = await resp.json();
statTotal.textContent = data.total_cards;
statToday.textContent = `+${data.today_added}`;
}
} catch (e) {}
}
async function fetchSrsStats() {
try {
const resp = await fetch('/api/srs/stats');
if (resp.ok) {
const stats = await resp.json();
const due = stats.due_cards || 0;
srsBadge.textContent = due;
const tabSrsBadge = document.getElementById('tab-srs-badge');
if (tabSrsBadge) {
tabSrsBadge.textContent = due;
if (due > 0) {
tabSrsBadge.classList.remove('hidden');
} else {
tabSrsBadge.classList.add('hidden');
}
}
}
} catch (e) {}
}
// -----------------------------------------------------------------------
// Duplicate Detection & Search Term
// -----------------------------------------------------------------------
let duplicateCheckTimer = null;
let foundDuplicateCard = null;
termInput.addEventListener('input', () => {
const val = termInput.value.trim();
clearSearchBtn.classList.toggle('hidden', val.length === 0);
clearTimeout(duplicateCheckTimer);
if (val.length >= 2) {
duplicateCheckTimer = setTimeout(async () => {
try {
const resp = await fetch(`/api/cards/check-duplicate?term=${encodeURIComponent(val)}`);
if (resp.ok) {
const res = await resp.json();
if (res.exists && res.card) {
foundDuplicateCard = res.card;
dupTermLabel.textContent = res.card.term;
dupTransLabel.textContent = res.card.translation;
duplicateWarningBox.classList.remove('hidden');
} else {
foundDuplicateCard = null;
duplicateWarningBox.classList.add('hidden');
}
}
} catch (e) {}
}, 300);
} else {
foundDuplicateCard = null;
duplicateWarningBox.classList.add('hidden');
}
});
dupOpenBtn.addEventListener('click', () => {
if (foundDuplicateCard) {
openEditModal(foundDuplicateCard);
}
});
clearSearchBtn.addEventListener('click', () => {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
duplicateWarningBox.classList.add('hidden');
lookupCard.classList.add('hidden');
termInput.focus();
});
// -----------------------------------------------------------------------
// Dictionary Lookup & Capture
// -----------------------------------------------------------------------
translateBtn.addEventListener('click', performLookup);
termInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
performLookup();
} else if (e.key === 'Escape') {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
duplicateWarningBox.classList.add('hidden');
lookupCard.classList.add('hidden');
}
});
async function performLookup() {
const term = termInput.value.trim();
if (!term) return;
translateBtn.disabled = true;
translateBtn.textContent = 'Ищем...';
try {
const resp = await fetch(`/api/lookup?term=${encodeURIComponent(term)}`);
if (resp.ok) {
const data = await resp.json();
currentLookupData = data;
displayLookupResult(data);
}
} catch (e) {
console.error('Lookup error:', e);
} finally {
translateBtn.disabled = false;
translateBtn.textContent = 'Перевести';
}
}
function displayLookupResult(data) {
resTerm.textContent = data.term;
resPhonetic.textContent = data.phonetic || '';
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
resPos.textContent = data.part_of_speech || 'Phrase';
audioPlayBtn.classList.add('hidden');
if (data.definition_en) {
resDefinition.textContent = data.definition_en;
resDefinitionWrap.classList.remove('hidden');
} else {
resDefinitionWrap.classList.add('hidden');
}
const inLib = Boolean(data.in_library || foundDuplicateCard);
if (resInLibrary) {
resInLibrary.classList.toggle('hidden', !inLib);
}
if (inLib) {
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
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 = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
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 = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
}
}
// -----------------------------------------------------------------------
// 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 = `
<div class="card-top">
<div class="card-term-group">
${isBatchMode ? `<input type="checkbox" class="card-select-checkbox batch-card-check" data-id="${c.id}" ${selectedIds.has(c.id) ? 'checked' : ''}>` : ''}
<span class="dot ${srsStateClass}" title="SRS статус: ${c.srs_state || 'new'}"></span>
<span class="card-term">${escapeHtml(c.term)}</span>
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
</div>
<div class="card-actions">
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
</div>
</div>
<div class="card-translation">${escapeHtml(c.translation)}</div>
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</div>` : ''}
<div class="card-bottom">
<div class="card-tags">
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
${c.tags ? c.tags.split(',').map(t => `<span class="card-tag">#${escapeHtml(t.trim())}</span>`).join(' ') : ''}
</div>
${c.notes ? `<div class="card-notes">💡 ${escapeHtml(c.notes)}</div>` : ''}
</div>
`;
// Edit button
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
e.stopPropagation();
openEditModal(c);
});
// Delete button
cardEl.querySelector('.delete-card-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Удалить слово "${c.term}" из словаря?`)) {
await fetch(`/api/cards/${c.id}`, { method: 'DELETE' });
await loadInitialData();
}
});
// Batch selection click
if (isBatchMode) {
cardEl.addEventListener('click', () => {
toggleBatchSelection(c.id);
});
}
cardsList.appendChild(cardEl);
});
}
// -----------------------------------------------------------------------
// Batch Operations
// -----------------------------------------------------------------------
batchToggleBtn.addEventListener('click', () => {
isBatchMode = !isBatchMode;
batchToggleBtn.textContent = isBatchMode ? '✓ Завершить выбор' : '☑️ Выбрать';
batchToggleBtn.classList.toggle('btn-primary', isBatchMode);
batchToggleBtn.classList.toggle('btn-outline', !isBatchMode);
batchActionBar.classList.toggle('hidden', !isBatchMode);
if (!isBatchMode) {
selectedIds.clear();
}
renderCardsList();
updateBatchCount();
});
batchCancelBtn.addEventListener('click', () => {
isBatchMode = false;
batchToggleBtn.textContent = '☑️ Выбрать';
batchToggleBtn.classList.remove('btn-primary');
batchToggleBtn.classList.add('btn-outline');
batchActionBar.classList.add('hidden');
selectedIds.clear();
renderCardsList();
});
function toggleBatchSelection(id) {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
updateBatchCount();
renderCardsList();
}
batchSelectAll.addEventListener('change', () => {
if (batchSelectAll.checked) {
allCards.forEach(c => selectedIds.add(c.id));
} else {
selectedIds.clear();
}
updateBatchCount();
renderCardsList();
});
function updateBatchCount() {
batchCountLabel.textContent = `Выбрано: ${selectedIds.size}`;
batchSelectAll.checked = selectedIds.size > 0 && selectedIds.size === allCards.length;
}
batchDeleteBtn.addEventListener('click', async () => {
if (selectedIds.size === 0) return;
if (confirm(`Удалить выбранные карточки (${selectedIds.size} шт.)?`)) {
await fetch('/api/cards/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_ids: Array.from(selectedIds),
action: 'delete',
}),
});
selectedIds.clear();
await loadInitialData();
}
});
batchMoveBtn.addEventListener('click', () => {
if (selectedIds.size === 0) {
alert('Сначала выберите хотя бы одну карточку');
return;
}
moveColModal.classList.remove('hidden');
});
batchTagBtn.addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const tagVal = prompt('Введите тег(и) для добавления (через запятую):');
if (tagVal && tagVal.trim()) {
await fetch('/api/cards/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_ids: Array.from(selectedIds),
action: 'add_tags',
value: tagVal.trim(),
}),
});
await loadInitialData();
}
});
// -----------------------------------------------------------------------
// Interactive SRS Trainer & View Switching (Hash Router Driven)
// -----------------------------------------------------------------------
srsTrainerBtn.addEventListener('click', () => {
window.location.hash = '#srs';
});
srsExitBtn.addEventListener('click', () => {
window.location.hash = '#dictionary';
});
srsMixBtn.addEventListener('click', () => {
srsSmartMix = !srsSmartMix;
srsMixLabel.textContent = srsSmartMix ? '🎲 Микс дня: ВКЛ' : '📚 Обычная очередь';
srsMixBtn.classList.toggle('text-success', srsSmartMix);
startSrsTrainingSession(false);
});
srsTabs.forEach(tab => {
tab.addEventListener('click', () => {
srsTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
srsCurrentMode = tab.dataset.mode;
renderSrsCurrentCard();
});
});
srsDirBtn.addEventListener('click', () => {
srsDirection = srsDirection === 'en-ru' ? 'ru-en' : 'en-ru';
srsDirLabel.textContent = srsDirection === 'en-ru' ? 'EN ➔ RU' : 'RU ➔ EN';
renderSrsCurrentCard();
});
function updateMobileActiveTab(tabName) {
document.querySelectorAll('.mobile-tab-bar .tab-item').forEach(btn => {
btn.classList.remove('active');
});
const target = document.getElementById(`tab-nav-${tabName}`);
if (target) target.classList.add('active');
}
function openDictionaryView(updateHash = true) {
if (updateHash && window.location.hash !== '#dictionary') {
window.location.hash = '#dictionary';
return;
}
srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
dictionaryView.classList.remove('hidden');
updateMobileActiveTab('dict');
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
loadInitialData();
}
function openSrsView(tagFilter = null, updateHash = true) {
const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs';
if (updateHash && window.location.hash !== targetHash) {
window.location.hash = targetHash;
return;
}
dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {}
startSrsTrainingSession(false, tagFilter);
}
function closeSrsView() {
openDictionaryView(true);
}
function openBookshelfView(updateHash = true) {
if (updateHash && window.location.hash !== '#reader') {
window.location.hash = '#reader';
return;
}
dictionaryView.classList.add('hidden');
srsView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
updateMobileActiveTab('reader');
try {
localStorage.setItem('notes_active_view', 'reader-view');
localStorage.removeItem('notes_active_book_id');
} catch (e) {}
loadBookshelf();
}
function openReaderView(updateHash = true) {
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastBookId) {
window.location.hash = `#reader/book/${lastBookId}`;
} else {
openBookshelfView(updateHash);
}
}
let isRouting = false;
async function handleRouting() {
if (isRouting) return;
isRouting = true;
try {
let hash = window.location.hash || '';
if (!hash || hash === '#') {
try {
const lastView = localStorage.getItem('notes_active_view');
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastView === 'reader-view') {
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
window.location.hash = hash;
return;
} else if (lastView === 'srs-view') {
hash = '#srs';
window.location.hash = hash;
return;
} else {
hash = '#dictionary';
}
} catch (e) {
hash = '#dictionary';
}
}
const cleanHash = hash.replace(/^#\/?/, '');
if (!cleanHash || cleanHash === 'dictionary') {
openDictionaryView(false);
} else if (cleanHash.startsWith('srs')) {
const parts = cleanHash.split('/');
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
openSrsView(tag, false);
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
const parts = cleanHash.split('/');
if (parts[1] === 'book' && parts[2]) {
const bookId = parseInt(parts[2], 10);
const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null;
await openBook(bookId, chIdx, false);
} else {
openBookshelfView(false);
}
} else {
openDictionaryView(false);
}
} finally {
isRouting = false;
}
}
window.addEventListener('hashchange', handleRouting);
function initMobileNavigation() {
const tabDict = document.getElementById('tab-nav-dict');
const tabSrs = document.getElementById('tab-nav-srs');
const tabReader = document.getElementById('tab-nav-reader');
const tabStats = document.getElementById('tab-nav-stats');
const tabMenu = document.getElementById('tab-nav-menu');
const mobileMenuModal = document.getElementById('mobile-menu-modal');
const mobileMenuToggleBtn = document.getElementById('mobile-menu-toggle-btn');
const mobileMenuCloseBtn = document.getElementById('mobile-menu-close-btn');
const mobileStatsBtn = document.getElementById('mobile-stats-btn');
const mobileExportBtn = document.getElementById('mobile-export-btn');
const mobilePwdBtn = document.getElementById('mobile-pwd-btn');
const mobileThemeBtn = document.getElementById('mobile-theme-btn');
const mobileLogoutBtn = document.getElementById('mobile-logout-btn');
if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; });
if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; });
if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; });
if (tabStats) {
tabStats.addEventListener('click', () => {
if (statsModal) {
statsModal.classList.remove('hidden');
loadStatsAndHeatmap();
}
});
}
function openMobileMenu() {
if (mobileMenuModal) {
const mobileUserName = document.getElementById('mobile-user-name');
if (mobileUserName && currentUser) {
mobileUserName.textContent = currentUser.username;
}
mobileMenuModal.classList.remove('hidden');
}
}
function closeMobileMenu() {
if (mobileMenuModal) {
mobileMenuModal.classList.add('hidden');
}
}
if (tabMenu) tabMenu.addEventListener('click', openMobileMenu);
if (mobileMenuToggleBtn) mobileMenuToggleBtn.addEventListener('click', openMobileMenu);
if (mobileMenuCloseBtn) mobileMenuCloseBtn.addEventListener('click', closeMobileMenu);
if (mobileMenuModal) {
mobileMenuModal.addEventListener('click', (e) => {
if (e.target === mobileMenuModal) closeMobileMenu();
});
}
if (mobileStatsBtn) {
mobileStatsBtn.addEventListener('click', () => {
closeMobileMenu();
if (statsModal) {
statsModal.classList.remove('hidden');
loadStatsAndHeatmap();
}
});
}
if (mobileExportBtn) {
mobileExportBtn.addEventListener('click', () => {
closeMobileMenu();
if (exportModal) exportModal.classList.remove('hidden');
});
}
if (mobilePwdBtn) {
mobilePwdBtn.addEventListener('click', () => {
closeMobileMenu();
if (changePwdModal) changePwdModal.classList.remove('hidden');
});
}
if (mobileThemeBtn) {
mobileThemeBtn.addEventListener('click', () => {
themeToggle.click();
});
}
if (mobileLogoutBtn) {
mobileLogoutBtn.addEventListener('click', () => {
closeMobileMenu();
logoutBtn.click();
});
}
}
navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; });
if (navReaderBtn) navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
initMobileNavigation();
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
srsLoading.classList.remove('hidden');
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
srsSessionRatings = [];
srsCurrentIndex = 0;
let queue = [];
try {
let url = forceAll
? `/api/srs/queue?limit=50`
: `/api/srs/queue?limit=25&mix=${srsSmartMix}`;
if (tagFilter) {
url += `&tag=${encodeURIComponent(tagFilter)}`;
}
const resp = await fetch(url);
if (resp.ok) {
const data = await resp.json();
queue = data.cards || [];
}
} catch (e) {
console.error('SRS Queue error:', e);
}
if (queue.length === 0 && forceAll && allCards.length > 0) {
queue = [...allCards];
}
srsLoading.classList.add('hidden');
if (queue.length === 0) {
srsEmpty.classList.remove('hidden');
return;
}
srsQueue = queue;
renderSrsCurrentCard();
}
srsTrainAllBtn.addEventListener('click', () => startSrsTrainingSession(true));
srsEmptyBackBtn.addEventListener('click', closeSrsView);
sumRepeatBtn.addEventListener('click', () => startSrsTrainingSession(true));
sumReturnBtn.addEventListener('click', closeSrsView);
function hideAllSrsModeContainers() {
srsModeFlashcards.classList.add('hidden');
srsModeQuiz.classList.add('hidden');
srsModeTyping.classList.add('hidden');
srsModeCloze.classList.add('hidden');
srsModeDictation.classList.add('hidden');
}
function renderSrsCurrentCard() {
if (srsCurrentIndex >= srsQueue.length) {
showSrsSummary();
return;
}
srsEmpty.classList.add('hidden');
srsSummary.classList.add('hidden');
hideAllSrsModeContainers();
const total = srsQueue.length;
const currentNum = srsCurrentIndex + 1;
srsCounter.textContent = `Карточка ${currentNum} из ${total}`;
const pct = Math.round(((currentNum - 1) / total) * 100);
srsProgressFill.style.width = `${pct}%`;
const card = srsQueue[srsCurrentIndex];
if (srsCurrentMode === 'flashcards') {
renderFlashcardMode(card);
} else if (srsCurrentMode === 'quiz') {
renderQuizMode(card);
} else if (srsCurrentMode === 'typing') {
renderTypingMode(card);
} else if (srsCurrentMode === 'cloze') {
renderClozeMode(card);
} else if (srsCurrentMode === 'dictation') {
renderDictationMode(card);
}
}
// MODE 1: Flashcards
function renderFlashcardMode(card) {
srsModeFlashcards.classList.remove('hidden');
srsIsFlipped = false;
flashcardInner.classList.remove('is-flipped');
fcRatingBar.classList.add('hidden');
fcHintBox.classList.add('hidden');
fcFlipBtnWrap.style.display = 'block';
if (srsDirection === 'en-ru') {
fcFrontDirBadge.textContent = 'EN ➔ RU';
fcFrontMain.textContent = card.term;
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
if (card.phonetic) {
fcFrontPhonetic.textContent = card.phonetic;
fcFrontPhoneticWrap.style.display = 'flex';
} else {
fcFrontPhoneticWrap.style.display = 'none';
}
fcHintBtn.style.display = card.context_sentence ? 'inline-flex' : 'none';
fcHintBox.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
// Back
fcBackTerm.textContent = card.term;
fcBackTranslation.textContent = card.translation;
fcBackPos.textContent = card.part_of_speech || '';
fcBackPos.style.display = card.part_of_speech ? 'inline-block' : 'none';
} else {
fcFrontDirBadge.textContent = 'RU ➔ EN';
fcFrontMain.textContent = card.translation;
fcFrontPos.textContent = card.part_of_speech || '';
fcFrontPhoneticWrap.style.display = 'none';
fcHintBtn.style.display = 'none';
// Back
fcBackTerm.textContent = card.term;
fcBackTranslation.textContent = card.translation;
fcBackPos.textContent = card.part_of_speech || '';
}
fcBackDef.textContent = card.definition_en || '';
fcBackDefBox.style.display = card.definition_en ? 'block' : 'none';
fcBackContext.textContent = card.context_sentence ? `“${card.context_sentence}”` : '';
fcBackContextBox.style.display = card.context_sentence ? 'block' : 'none';
fcBackNotes.textContent = card.notes || '';
fcBackNotesBox.style.display = card.notes ? 'block' : 'none';
fcFrontAudioBtn.style.display = 'none';
fcBackAudioBtn.style.display = 'none';
}
function flipFlashcard() {
if (srsIsFlipped) return;
srsIsFlipped = true;
flashcardInner.classList.add('is-flipped');
fcRatingBar.classList.remove('hidden');
fcFlipBtnWrap.style.display = 'none';
}
flashcardFlipWrap.addEventListener('click', flipFlashcard);
fcFlipBtn.addEventListener('click', flipFlashcard);
fcHintBtn.addEventListener('click', (e) => {
e.stopPropagation();
fcHintBox.classList.toggle('hidden');
});
// Rating Submission for Flashcards
document.querySelectorAll('.btn-rate').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const rating = parseInt(btn.dataset.rating, 10);
await submitCardReview(rating);
});
});
async function submitCardReview(rating) {
const card = srsQueue[srsCurrentIndex];
if (!card) return;
srsSessionRatings.push(rating);
try {
await fetch('/api/srs/review', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_id: card.id, rating: rating }),
});
} catch (e) {
console.error('Review submission error:', e);
}
srsCurrentIndex++;
renderSrsCurrentCard();
}
// MODE 2: Multiple Choice Quiz (4 Options)
function renderQuizMode(card) {
srsModeQuiz.classList.remove('hidden');
quizAnswered = false;
quizFeedbackBox.classList.add('hidden');
quizDetailsBox.classList.add('hidden');
quizNextBar.classList.add('hidden');
quizPos.textContent = card.part_of_speech || '';
quizTermDisplay.textContent = card.term;
quizPhoneticDisplay.textContent = card.phonetic || '';
quizPhoneticDisplay.style.display = card.phonetic ? 'block' : 'none';
if (card.context_sentence) {
quizContextHint.textContent = `“${card.context_sentence}”`;
quizContextHint.classList.remove('hidden');
} else {
quizContextHint.classList.add('hidden');
}
quizAudioBtn.classList.add('hidden');
// Generate 4 options: target + 3 random from other cards or presets
const correctText = card.translation;
const candidates = allCards
.filter(c => c.id !== card.id && c.translation && c.translation !== correctText)
.map(c => c.translation);
shuffleArray(candidates);
const options = [correctText, ...candidates.slice(0, 3)];
while (options.length < 4) {
options.push(`Вариант ${options.length + 1}`);
}
shuffleArray(options);
quizCurrentOptions = options;
quizOptionsGrid.innerHTML = '';
options.forEach((optText, idx) => {
const btn = document.createElement('button');
btn.className = 'quiz-option-btn';
btn.innerHTML = `
<span class="quiz-opt-num">${idx + 1}</span>
<span class="quiz-opt-text">${escapeHtml(optText)}</span>
`;
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 = '🎉 <strong>Отлично! Правильный ответ.</strong>';
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 = `❌ <strong>Ошибка!</strong> Правильно: <em>${escapeHtml(correctText)}</em>`;
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 ? `<p><strong>Толкование:</strong> ${escapeHtml(card.definition_en)}</p>` : ''}
${card.context_sentence ? `<p><strong>Контекст:</strong> <em>“${escapeHtml(card.context_sentence)}”</em></p>` : ''}
`;
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 = `✅ <strong>Верно!</strong> <em>${escapeHtml(card.term)}</em> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
} else {
typingFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
typingFeedbackBox.innerHTML = `❌ <strong>Не совсем:</strong> правильное написание — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
}
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, '<span class="gap-placeholder">_____</span>');
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 = `✅ <strong>Верно!</strong> целевое слово: <strong>${escapeHtml(card.term)}</strong>`;
} else {
clozeFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
clozeFeedbackBox.innerHTML = `❌ Правильный ответ: <strong>${escapeHtml(card.term)}</strong>`;
}
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 = `✅ <strong>Идеально!</strong> <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''} — ${escapeHtml(card.translation)}`;
} else {
dictationFeedbackBox.className = 'trainer-feedback-box feedback-wrong';
dictationFeedbackBox.innerHTML = `❌ <strong>Ошибка спеллинга:</strong> правильное слово — <strong>${escapeHtml(card.term)}</strong> ${card.phonetic ? `<code>${escapeHtml(card.phonetic)}</code>` : ''}`;
}
dictationFeedbackBox.classList.remove('hidden');
renderDynamicRatingBar(dictationRatingBar, isMatch ? 3 : 1);
});
function renderDynamicRatingBar(container, defaultRating) {
container.innerHTML = `
<button class="btn-rate btn-rate-1" data-rating="1"><span class="rate-key">[1]</span> Снова</button>
<button class="btn-rate btn-rate-2" data-rating="2"><span class="rate-key">[2]</span> Трудно</button>
<button class="btn-rate btn-rate-3" data-rating="3"><span class="rate-key">[3]</span> Хорошо</button>
<button class="btn-rate btn-rate-4" data-rating="4"><span class="rate-key">[4]</span> Легко</button>
`;
container.classList.remove('hidden');
container.querySelectorAll('.btn-rate').forEach(btn => {
btn.onclick = async () => {
const r = parseInt(btn.dataset.rating, 10);
await submitCardReview(r);
};
});
}
function showSrsSummary() {
hideAllSrsModeContainers();
srsSummary.classList.remove('hidden');
srsProgressFill.style.width = '100%';
srsCounter.textContent = 'Тренировка завершена!';
const total = srsSessionRatings.length;
sumCount.textContent = total;
const goodCount = srsSessionRatings.filter(r => r >= 3).length;
const retention = total > 0 ? Math.round((goodCount / total) * 100) : 100;
sumRetention.textContent = `${retention}%`;
fetchSrsStats();
}
// Hotkeys handler
document.addEventListener('keydown', (e) => {
if (!srsView.classList.contains('hidden')) {
if (srsCurrentMode === 'flashcards') {
if (e.code === 'Space' && !srsIsFlipped) {
e.preventDefault();
flipFlashcard();
} else if (srsIsFlipped && ['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
submitCardReview(parseInt(e.key, 10));
}
} else if (srsCurrentMode === 'quiz' && !quizAnswered) {
if (['1', '2', '3', '4'].includes(e.key)) {
const idx = parseInt(e.key, 10) - 1;
const btns = quizOptionsGrid.querySelectorAll('.quiz-option-btn');
if (btns[idx]) {
e.preventDefault();
btns[idx].click();
}
}
} else if (srsCurrentMode === 'quiz' && quizAnswered) {
if (e.key === 'Enter' || e.code === 'Space') {
e.preventDefault();
quizNextBtn.click();
}
}
}
});
// -----------------------------------------------------------------------
// Analytics Dashboard & GitHub-Style Heatmap Modal
// -----------------------------------------------------------------------
statsOpenBtn.addEventListener('click', openStatsDashboard);
closeStatsModalBtn.addEventListener('click', () => statsModal.classList.add('hidden'));
closeStatsBtn.addEventListener('click', () => statsModal.classList.add('hidden'));
async function openStatsDashboard() {
statsModal.classList.remove('hidden');
await Promise.all([loadStatsKpis(), loadActivityHeatmap()]);
}
async function loadStatsKpis() {
try {
const resp = await fetch('/api/srs/stats');
if (resp.ok) {
const s = await resp.json();
kpiCurrentStreak.textContent = `🔥 ${s.streak_days}`;
kpiLongestStreak.textContent = `Рекорд: ${s.longest_streak} дн.`;
kpiRetentionRate.textContent = `${s.retention_rate}%`;
kpiTotalReviews.textContent = `Всего повторений: ${s.total_reviews}`;
kpiTotalWords.textContent = s.total_cards;
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
// Strict CEFR Level estimation (PLAN.md 4.3)
if (s.cefr) {
kpiCefrLevel.textContent = s.cefr.level;
kpiCefrDesc.textContent = `${s.cefr.title} • ${s.cefr.message}`;
} else {
const count = s.total_cards;
let lvl = 'A1';
let desc = 'Базовый словарный запас';
if (count >= 2500) { lvl = 'C1'; desc = 'Продвинутый свободный уровень'; }
else if (count >= 1200) { lvl = 'B2'; desc = 'Уверенное чтение тех. документации'; }
else if (count >= 600) { lvl = 'B1'; desc = 'Понимание общих и рабочих тем'; }
else if (count >= 200) { lvl = 'A2'; desc = 'Элементарный словарный запас'; }
kpiCefrLevel.textContent = lvl;
kpiCefrDesc.textContent = desc;
}
// Mastery Progress Bar
cntMastered.textContent = s.mastered;
cntReview.textContent = s.review;
cntLearning.textContent = s.learning;
cntNew.textContent = s.new_cards;
cntLeeches.textContent = s.leech_count;
const total = s.total_cards || 1;
progMastered.style.width = `${(s.mastered / total) * 100}%`;
progReview.style.width = `${(s.review / total) * 100}%`;
progLearning.style.width = `${(s.learning / total) * 100}%`;
progNew.style.width = `${(s.new_cards / total) * 100}%`;
}
} catch (e) {
console.error('Stats load error:', e);
}
}
async function loadActivityHeatmap() {
try {
const resp = await fetch('/api/srs/activity-heatmap?days=180');
if (resp.ok) {
const data = await resp.json();
renderHeatmap(data.days);
}
} catch (e) {
console.error('Heatmap load error:', e);
}
}
function renderHeatmap(days) {
heatmapGrid.innerHTML = '';
if (!days || days.length === 0) return;
days.forEach(d => {
const cell = document.createElement('div');
cell.className = `heatmap-cell lvl-${d.level}`;
cell.title = `${d.date}: ${d.count} повторений / тестов`;
heatmapGrid.appendChild(cell);
});
}
// -----------------------------------------------------------------------
// Edit Card Modal
// -----------------------------------------------------------------------
function openEditModal(card) {
modalCardId.value = card.id;
modalTerm.value = card.term;
modalTranslation.value = card.translation;
modalContext.value = card.context_sentence || '';
modalPhonetic.value = card.phonetic || '';
modalPos.value = card.part_of_speech || '';
modalDef.value = card.definition_en || '';
modalCollection.value = card.collection || 'General';
modalTags.value = card.tags || '';
modalNotes.value = card.notes || '';
editModal.classList.remove('hidden');
}
closeModalBtn.addEventListener('click', () => editModal.classList.add('hidden'));
modalCancelBtn.addEventListener('click', () => editModal.classList.add('hidden'));
modalSaveBtn.addEventListener('click', async () => {
const id = modalCardId.value;
const payload = {
term: modalTerm.value.trim(),
translation: modalTranslation.value.trim(),
context_sentence: modalContext.value.trim(),
phonetic: modalPhonetic.value.trim(),
part_of_speech: modalPos.value.trim(),
definition_en: modalDef.value.trim(),
collection: modalCollection.value.trim() || 'General',
tags: modalTags.value.trim(),
notes: modalNotes.value.trim(),
};
const resp = await fetch(`/api/cards/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
editModal.classList.add('hidden');
await loadInitialData();
} else {
alert('Ошибка при сохранении карточки');
}
});
// -----------------------------------------------------------------------
// Export Modal
// -----------------------------------------------------------------------
exportOpenBtn.addEventListener('click', () => {
exportModal.classList.remove('hidden');
});
closeExportModalBtn.addEventListener('click', () => exportModal.classList.add('hidden'));
exportCancelBtn.addEventListener('click', () => exportModal.classList.add('hidden'));
document.querySelectorAll('.export-tile').forEach(tile => {
tile.addEventListener('click', () => {
const format = tile.dataset.format;
let url = `/api/export/${format}`;
if (selectedCollection) url += `?collection=${encodeURIComponent(selectedCollection)}`;
window.location.href = url;
});
});
// -----------------------------------------------------------------------
// Collections Management
// -----------------------------------------------------------------------
newCollectionBtn.addEventListener('click', () => {
newColModal.classList.remove('hidden');
newColNameInput.value = '';
newColNameInput.focus();
});
closeNewColModalBtn.addEventListener('click', () => newColModal.classList.add('hidden'));
newColCancelBtn.addEventListener('click', () => newColModal.classList.add('hidden'));
newColSaveBtn.addEventListener('click', () => {
const name = newColNameInput.value.trim();
if (name) {
selectedCollection = name;
newColModal.classList.add('hidden');
fetchCards();
}
});
closeMoveModalBtn.addEventListener('click', () => moveColModal.classList.add('hidden'));
moveColCancelBtn.addEventListener('click', () => moveColModal.classList.add('hidden'));
moveColConfirmBtn.addEventListener('click', async () => {
const col = moveColInput.value.trim();
if (col && selectedIds.size > 0) {
await fetch('/api/cards/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
card_ids: Array.from(selectedIds),
action: 'set_collection',
value: col,
}),
});
moveColModal.classList.add('hidden');
selectedIds.clear();
await loadInitialData();
}
});
function populateDatalists() {
collectionsDatalist.innerHTML = '';
const dl = document.getElementById('move-collections-datalist');
if (dl) dl.innerHTML = '';
collectionsList.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
collectionsDatalist.appendChild(opt);
if (dl) dl.appendChild(opt.cloneNode(true));
});
if (moveColQuickList) {
moveColQuickList.innerHTML = '';
collectionsList.forEach(c => {
const btn = document.createElement('button');
btn.className = 'btn btn-xs btn-outline';
btn.textContent = c;
btn.onclick = () => { moveColInput.value = c; };
moveColQuickList.appendChild(btn);
});
}
}
// -----------------------------------------------------------------------
// Change Password Modal
// -----------------------------------------------------------------------
changePwdOpenBtn.addEventListener('click', () => {
changePasswordModal.classList.remove('hidden');
changePwdError.classList.add('hidden');
changePwdSuccess.classList.add('hidden');
currentPasswordInput.value = '';
newPasswordInput.value = '';
confirmPasswordInput.value = '';
currentPasswordInput.focus();
});
closeChangePwdBtn.addEventListener('click', () => changePasswordModal.classList.add('hidden'));
changePwdCancelBtn.addEventListener('click', () => changePasswordModal.classList.add('hidden'));
changePasswordForm.addEventListener('submit', async (e) => {
e.preventDefault();
changePwdError.classList.add('hidden');
changePwdSuccess.classList.add('hidden');
if (newPasswordInput.value !== confirmPasswordInput.value) {
changePwdError.textContent = 'Новые пароли не совпадают';
changePwdError.classList.remove('hidden');
return;
}
try {
const resp = await fetch('/api/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_password: currentPasswordInput.value,
new_password: newPasswordInput.value,
}),
});
const data = await resp.json();
if (resp.ok) {
changePwdSuccess.textContent = 'Пароль успешно обновлен!';
changePwdSuccess.classList.remove('hidden');
setTimeout(() => changePasswordModal.classList.add('hidden'), 1200);
} else {
changePwdError.textContent = data.detail || 'Ошибка смены пароля';
changePwdError.classList.remove('hidden');
}
} catch (err) {
changePwdError.textContent = 'Ошибка сетевого соединения';
changePwdError.classList.remove('hidden');
}
});
// =======================================================================
// STAGE 5: Interactive Reader & Bookshelf Implementation
// =======================================================================
function slugify(text) {
if (!text) return 'book';
return text.toString().toLowerCase().trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function loadBookshelf() {
if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) {
bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
}
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
try {
const resp = await fetch('/api/books');
if (resp.ok) {
const books = await resp.json();
if (!books || books.length === 0) {
if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
} else {
renderBookshelf(books);
}
}
} catch (e) {
console.error('Error loading bookshelf:', e);
}
}
function renderBookshelf(books) {
if (!bookshelfGrid) return;
bookshelfGrid.innerHTML = '';
books.forEach(b => {
const card = document.createElement('div');
card.className = 'book-card';
const totalCh = Math.max(1, b.chapters_count || 1);
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
const pos = (typeof b.current_position === 'number') ? Math.max(0, Math.min(1.0, b.current_position)) : 0.0;
const progressFraction = (chIndex + pos) / totalCh;
const totalPct = Math.min(100, Math.max(0, Math.round(progressFraction * 100)));
const chPct = Math.min(100, Math.max(0, Math.round(pos * 100)));
const progressLabels = totalCh === 1
? `<span>1 глава • ${totalPct}%</span><span>${totalPct}%</span>`
: `<span>Глава ${chIndex + 1} из ${totalCh} (${chPct}%)</span><span>${totalPct}%</span>`;
card.innerHTML = `
<div>
<div class="book-card-top">
<div class="book-cover-icon">${b.source_type === 'epub' ? '📕' : '📄'}</div>
<div class="book-card-info">
<h4 class="book-card-title" title="${escapeHtml(b.title)}">${escapeHtml(b.title)}</h4>
<p class="book-card-author">${escapeHtml(b.author || 'Неизвестный автор')}</p>
<div class="book-card-badges">
<span class="badge-format">${escapeHtml(b.source_type)}</span>
<span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span>
</div>
</div>
</div>
<div class="book-progress-wrap">
<div class="book-progress-labels">
${progressLabels}
</div>
<div class="book-progress-bar-bg">
<div class="book-progress-bar-fill" style="width: ${totalPct}%"></div>
</div>
</div>
</div>
<div class="book-card-actions">
<button class="btn btn-primary btn-sm read-book-btn" data-id="${b.id}">📖 Читать</button>
<button class="btn btn-outline btn-xs train-book-btn" data-title="${escapeHtml(b.title)}" title="Тренировать слова из этой книги">🎓 Слова книги</button>
<button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить книгу">🗑️</button>
</div>
`;
card.querySelector('.read-book-btn').addEventListener('click', () => {
window.location.hash = `#reader/book/${b.id}`;
});
card.querySelector('.train-book-btn').addEventListener('click', () => {
const bookTag = slugify(b.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
await fetch(`/api/books/${b.id}`, { method: 'DELETE' });
loadBookshelf();
}
});
bookshelfGrid.appendChild(card);
});
}
async function openBook(bookId, specificChapterIdx = null, updateHash = true) {
if (!bookId) return;
if (readerView) readerView.classList.remove('hidden');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (bookshelfContainer) bookshelfContainer.classList.add('hidden');
if (readerInterface) readerInterface.classList.remove('hidden');
updateMobileActiveTab('reader');
if (currentBook && currentBook.id === bookId) {
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
if (specificChapterIdx !== currentChapterIndex) {
renderChapter(specificChapterIdx, 0, 0, 0);
}
}
if (updateHash) {
const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
}
return;
}
try {
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
const resp = await fetch(`/api/books/${bookId}`);
if (!resp.ok) {
alert('Не удалось открыть книгу');
window.location.hash = '#reader';
return;
}
currentBook = await resp.json();
// Load saved reading position from localStorage or server
let targetChapter = currentBook.current_chapter || 0;
let targetScrollTop = null;
let targetFraction = currentBook.current_position || 0;
let targetAnchorIdx = null;
try {
const raw = localStorage.getItem(`notes_book_${bookId}_pos`);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.chapter === 'number') {
targetChapter = parsed.chapter;
targetScrollTop = parsed.scrollTop;
targetFraction = parsed.fraction;
targetAnchorIdx = parsed.anchorIdx;
}
}
} catch (e) {}
if (specificChapterIdx !== null && specificChapterIdx !== undefined && !isNaN(specificChapterIdx)) {
targetChapter = specificChapterIdx;
if (specificChapterIdx !== (currentBook.current_chapter || 0)) {
targetScrollTop = 0;
targetFraction = 0;
targetAnchorIdx = 0;
}
}
currentChapterIndex = Math.max(0, Math.min(targetChapter, (currentBook.chapters ? currentBook.chapters.length - 1 : 0)));
readerBookTitle.textContent = currentBook.title;
readerBookAuthor.textContent = currentBook.author || '';
// Populate chapter selector with visual status markers
readerChapterSelect.innerHTML = '';
const chapters = currentBook.chapters || [];
chapters.forEach((ch, idx) => {
const opt = document.createElement('option');
opt.value = idx;
const statusIcon = idx < currentChapterIndex ? '✔ ' : (idx === currentChapterIndex ? '▶ ' : '○ ');
opt.textContent = `${statusIcon}${ch.title || `Глава ${idx + 1}`}`;
readerChapterSelect.appendChild(opt);
});
applyReaderSettings();
try {
localStorage.setItem('notes_active_book_id', bookId);
localStorage.setItem('notes_active_view', 'reader-view');
} catch (e) {}
if (updateHash) {
const desiredHash = `#reader/book/${bookId}/ch/${currentChapterIndex}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
}
renderChapter(currentChapterIndex, targetScrollTop, targetFraction, targetAnchorIdx);
} catch (e) {
console.error('Error opening book:', e);
}
}
function renderChapter(index, restoreScrollTop = null, restoreFraction = null, restoreAnchorIdx = null) {
if (!currentBook || !currentBook.chapters) return;
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
readerChapterSelect.value = currentChapterIndex;
// Refresh status icons in chapter select
if (readerChapterSelect.options) {
for (let i = 0; i < readerChapterSelect.options.length; i++) {
const rawTitle = currentBook.chapters[i]?.title || `Глава ${i + 1}`;
const icon = i < currentChapterIndex ? '✔ ' : (i === currentChapterIndex ? '▶ ' : '○ ');
readerChapterSelect.options[i].textContent = `${icon}${rawTitle}`;
}
}
readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
readerContentBody.innerHTML = '';
if (chapter.title) {
const h = document.createElement('h2');
h.textContent = chapter.title;
h.setAttribute('data-block-idx', '0');
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, blockIdx) => {
const domIdx = blockIdx + 1;
let blockEl = null;
if (block.type === 'code') {
blockEl = document.createElement('div');
blockEl.className = 'reader-code-block';
const header = document.createElement('div');
header.className = 'reader-code-header';
const langSpan = document.createElement('span');
langSpan.className = 'reader-code-lang';
langSpan.textContent = block.lang || 'code';
const copyBtn = document.createElement('button');
copyBtn.className = 'reader-copy-code-btn';
copyBtn.type = 'button';
copyBtn.innerHTML = '📋 Копировать';
copyBtn.onclick = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(block.code);
copyBtn.innerHTML = '✅ Скопировано!';
setTimeout(() => { copyBtn.innerHTML = '📋 Копировать'; }, 2000);
} catch (err) {
console.error('Copy failed:', err);
}
};
header.appendChild(langSpan);
header.appendChild(copyBtn);
const pre = document.createElement('pre');
const codeEl = document.createElement('code');
if (block.lang) codeEl.className = `language-${block.lang}`;
codeEl.textContent = block.code;
pre.appendChild(codeEl);
blockEl.appendChild(header);
blockEl.appendChild(pre);
} else if (block.type === 'table') {
blockEl = document.createElement('div');
blockEl.className = 'reader-table-wrap';
const table = document.createElement('table');
table.className = 'reader-table';
if (block.headers && block.headers.length > 0) {
const thead = document.createElement('thead');
const headerTr = document.createElement('tr');
block.headers.forEach((hText, colIdx) => {
const th = document.createElement('th');
if (block.alignments && block.alignments[colIdx]) {
th.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(th, hText, hText, knownWordsMap);
headerTr.appendChild(th);
});
thead.appendChild(headerTr);
table.appendChild(thead);
}
if (block.rows && block.rows.length > 0) {
const tbody = document.createElement('tbody');
block.rows.forEach(row => {
const tr = document.createElement('tr');
row.forEach((cellText, colIdx) => {
const td = document.createElement('td');
if (block.alignments && block.alignments[colIdx]) {
td.style.textAlign = block.alignments[colIdx];
}
renderInteractiveSpans(td, cellText, cellText, knownWordsMap);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
}
blockEl.appendChild(table);
} else if (block.type === 'heading') {
const tag = `h${Math.min(4, Math.max(1, block.level || 2))}`;
blockEl = document.createElement(tag);
if (block.align) blockEl.style.textAlign = block.align;
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
} else if (block.type === 'blockquote') {
blockEl = document.createElement('blockquote');
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
} else if (block.type === 'ul' || block.type === 'ol') {
blockEl = document.createElement(block.type);
block.items.forEach(itemText => {
const li = document.createElement('li');
renderInteractiveSpans(li, itemText, itemText, knownWordsMap);
blockEl.appendChild(li);
});
} else if (block.type === 'hr') {
blockEl = document.createElement('hr');
} else {
// paragraph
blockEl = document.createElement('p');
if (block.align) {
blockEl.style.textAlign = block.align;
blockEl.classList.add('text-center');
}
renderInteractiveSpans(blockEl, block.text, block.text, knownWordsMap);
}
if (blockEl) {
blockEl.setAttribute('data-block-idx', domIdx.toString());
readerContentBody.appendChild(blockEl);
}
});
function applyScroll() {
if (!readerScrollArea) return;
if (restoreAnchorIdx !== null && restoreAnchorIdx !== undefined) {
const el = readerContentBody.querySelector(`[data-block-idx="${restoreAnchorIdx}"]`);
if (el) {
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
readerScrollArea.scrollTop += (elTop - containerTop - 10);
updateReaderProgressBar();
return;
}
}
if (restoreScrollTop !== null && restoreScrollTop > 0) {
readerScrollArea.scrollTop = restoreScrollTop;
} else if (restoreFraction !== null && restoreFraction > 0) {
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
readerScrollArea.scrollTop = Math.round(maxScroll * restoreFraction);
} else {
readerScrollArea.scrollTop = 0;
}
updateReaderProgressBar();
}
requestAnimationFrame(() => {
applyScroll();
setTimeout(applyScroll, 100);
});
// Save progress to server
const posToSave = (restoreFraction !== null && restoreFraction > 0) ? restoreFraction : 0;
fetch(`/api/books/${currentBook.id}/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: posToSave }),
}).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(/(?<!\\)\|/).map(c => 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: <h1...>...</h1>
const htmlHeadingMatch = line.match(/^<h([1-6])([^>]*)>(.*?)<\/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 <hr>
if (/^(\-{3,}|\*{3,}|\_{3,})$/.test(trimmed) || trimmed === '<hr>' || trimmed === '<hr/>' || trimmed === '<hr />') {
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 (<p align="center"> or <div align="center">)
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) ||
/^<h[1-6][^>]*>/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(/&middot;/g, '·')
.replace(/&bull;/g, '•')
.replace(/&mdash;/g, '—')
.replace(/&ndash;/g, '–');
// Strip enclosing <p...> and </p> tags if present
processed = processed.replace(/^<p[^>]*>/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) -> <img src="url" alt="alt">
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
// Links: [text](url) -> <a href="url">$1</a>
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
// Inline Code: `code` -> <code class="reader-inline-code">$1</code>
processed = processed.replace(/`([^`]+)`/g, '<code class="reader-inline-code">$1</code>');
// Bold: **text** or __text__ -> <strong>text</strong>
processed = processed.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
processed = processed.replace(/__([^_]+)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_ -> <em>text</em>
processed = processed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
processed = processed.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>');
// 3. Parse into DOM tree
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${processed}</body>`, 'text/html');
// 4. Recursive transformer: keeps formatting elements, makes text nodes interactive words
function transformDOMNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const raw = node.textContent;
if (!raw) return document.createTextNode('');
const frag = document.createDocumentFragment();
const tokens = raw.split(/([A-Za-z0-9'-]+)/);
for (const token of tokens) {
const isWord = /^[A-Za-z0-9'-]+$/.test(token) && token.replace(/[^A-Za-z0-9]/g, '').length >= 1;
if (isWord) {
const span = document.createElement('span');
span.className = 'reader-word';
span.dataset.word = token;
span.textContent = token;
const lower = token.toLowerCase();
if (knownWordsMap && knownWordsMap.has(lower)) {
const card = knownWordsMap.get(lower);
span.classList.add(card.srs_state === 'mastered' ? 'status-mastered' : 'status-learning');
}
if (isHighlightEnabled) {
span.classList.add('highlight-enabled');
}
span.addEventListener('click', (e) => {
e.stopPropagation();
handleWordClick(token, contextText || raw, span);
});
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(token));
}
}
return frag;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
// Code and pre: keep as code badge, DO NOT tokenize words!
if (tag === 'code' || tag === 'pre') {
const codeEl = document.createElement('code');
codeEl.className = 'reader-inline-code';
codeEl.textContent = node.textContent;
return codeEl;
}
// Images
if (tag === 'img') {
const img = document.createElement('img');
img.className = 'reader-img';
img.src = node.getAttribute('src') || '';
img.alt = node.getAttribute('alt') || '';
if (node.getAttribute('width')) img.setAttribute('width', node.getAttribute('width'));
if (node.getAttribute('height')) img.setAttribute('height', node.getAttribute('height'));
// Hide gracefully if broken/local relative asset
img.addEventListener('error', () => {
img.style.display = 'none';
});
return img;
}
// Links
if (tag === 'a') {
const a = document.createElement('a');
a.className = 'reader-link';
a.href = node.getAttribute('href') || '#';
a.target = '_blank';
a.rel = 'noopener noreferrer';
Array.from(node.childNodes).forEach(child => {
a.appendChild(transformDOMNode(child));
});
return a;
}
if (tag === 'br') {
return document.createElement('br');
}
if (tag === 'strong' || tag === 'b') {
const strong = document.createElement('strong');
Array.from(node.childNodes).forEach(child => {
strong.appendChild(transformDOMNode(child));
});
return strong;
}
if (tag === 'em' || tag === 'i') {
const em = document.createElement('em');
Array.from(node.childNodes).forEach(child => {
em.appendChild(transformDOMNode(child));
});
return em;
}
if (tag === 'sub' || tag === 'sup') {
const el = document.createElement(tag);
Array.from(node.childNodes).forEach(child => {
el.appendChild(transformDOMNode(child));
});
return el;
}
// Generic containers (span, div, etc.)
const containerEl = document.createElement(tag === 'p' ? 'span' : tag);
if (node.hasAttribute('style')) {
containerEl.setAttribute('style', node.getAttribute('style'));
}
Array.from(node.childNodes).forEach(child => {
containerEl.appendChild(transformDOMNode(child));
});
return containerEl;
}
return document.createTextNode('');
}
Array.from(doc.body.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode));
});
}
// Extract cleanest surrounding sentence for context
function extractContextSentence(fullText, word) {
if (!fullText) return '';
const re = new RegExp(`([^.!?\\n]*?\\b${escapeRegex(word)}\\b[^.!?\\n]*[.!?]?)`, 'i');
const m = fullText.match(re);
if (m && m[1]) return m[1].trim();
return fullText.slice(0, 160).trim();
}
async function handleWordClick(word, paragraphText, targetElement) {
const sentence = extractContextSentence(paragraphText, word);
openWordPopup(word, sentence, targetElement);
}
// Precise popup positioning near target element without scroll double-offset
function positionWordPopup(targetElement) {
if (!targetElement || !readerWordPopup) return;
if (window.innerWidth <= 768) {
readerWordPopup.style.top = '';
readerWordPopup.style.left = '';
return;
}
const rect = targetElement.getBoundingClientRect();
const popupWidth = 320;
const popupHeight = readerWordPopup.offsetHeight || 220;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Center horizontally over target element
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
if (left + popupWidth > viewportWidth - 12) {
left = viewportWidth - popupWidth - 12;
}
if (left < 12) {
left = 12;
}
// Position vertically: default below the word
let top = rect.bottom + 8;
// If bottom overflows viewport, flip above the word
if (top + popupHeight > viewportHeight - 12) {
if (rect.top - popupHeight - 8 >= 65 || rect.top > viewportHeight / 2) {
top = Math.max(65, rect.top - popupHeight - 8);
}
}
readerWordPopup.style.top = `${Math.round(top)}px`;
readerWordPopup.style.left = `${Math.round(left)}px`;
}
async function openWordPopup(term, contextSentence, targetElement) {
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
if (!cleanTerm) return;
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
const lowerClean = cleanTerm.toLowerCase();
const existingCard = allCards && allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
popupCurrentData = {
term: cleanTerm,
contextSentence: contextSentence,
targetElement: targetElement,
existingCard: existingCard,
};
popupTerm.textContent = cleanTerm;
popupPhonetic.textContent = '';
popupTranslation.textContent = '';
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
popupLoading.classList.remove('hidden');
popupContent.classList.add('hidden');
// Immediate check against already-loaded user cards
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
if (existingCard) {
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
if (popupAddContextBtn && contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else if (popupAddContextBtn) {
popupAddContextBtn.classList.add('hidden');
}
} else {
popupSavedBadge.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
// Position popup near target element
readerWordPopup.classList.remove('hidden');
positionWordPopup(targetElement);
try {
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(cleanTerm)}`);
if (resp.ok) {
const data = await resp.json();
popupCurrentData.lookup = data;
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
popupPhonetic.textContent = data.phonetic || '';
popupTranslation.textContent = trans || 'Перевод не найден';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
popupAudioBtn.classList.add('hidden');
const inLib = Boolean(data.in_library || existingCard);
const libCard = data.library_card || existingCard;
if (inLib) {
popupCurrentData.existingCard = libCard;
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
if (popupAddContextBtn && contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else if (popupAddContextBtn) {
popupAddContextBtn.classList.add('hidden');
}
} else {
if (popupInLibrary) popupInLibrary.classList.add('hidden');
popupSaveBtn.classList.remove('hidden');
popupSavedBadge.classList.add('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
positionWordPopup(targetElement);
} else {
popupTranslation.textContent = 'Ошибка перевода';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
positionWordPopup(targetElement);
}
} catch (e) {
console.error('Popup lookup error:', e);
popupTranslation.textContent = 'Сетевая ошибка';
popupLoading.classList.add('hidden');
popupContent.classList.remove('hidden');
positionWordPopup(targetElement);
}
}
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
readerWordPopup.classList.add('hidden');
});
}
document.addEventListener('click', (e) => {
if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
readerWordPopup.classList.add('hidden');
}
});
function updatePopupPositionOnScroll() {
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (window.innerWidth <= 768) return; // Mobile uses fixed bottom sheet
if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) {
readerWordPopup.classList.add('hidden');
return;
}
const rect = popupCurrentData.targetElement.getBoundingClientRect();
const headerOffset = 65; // reader toolbar sticky height
if (rect.bottom < headerOffset || rect.top > window.innerHeight) {
readerWordPopup.classList.add('hidden');
return;
}
positionWordPopup(popupCurrentData.targetElement);
}
let popupScrollRaf = null;
function schedulePopupUpdate() {
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (popupScrollRaf) return;
popupScrollRaf = requestAnimationFrame(() => {
popupScrollRaf = null;
updatePopupPositionOnScroll();
});
}
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
if (popupSaveBtn) {
popupSaveBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent,
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
try {
const resp = await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
const resData = await resp.json();
popupSaveBtn.classList.add('hidden');
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = resData.already_exists ? '⭐ Уже есть в библиотеке' : '✅ Сохранено в словарь!';
if (popupInLibrary) popupInLibrary.classList.remove('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
if (d.targetElement) {
d.targetElement.classList.add('status-learning');
if (isHighlightEnabled) d.targetElement.classList.add('highlight-enabled');
}
await fetchCards();
await fetchTags();
} else {
alert('Не удалось сохранить слово');
}
} catch (e) {
console.error('Save card from reader error:', e);
}
});
}
if (popupAddContextBtn) {
popupAddContextBtn.addEventListener('click', async () => {
if (!popupCurrentData) return;
const d = popupCurrentData;
const lookup = d.lookup || {};
const bookTag = currentBook ? slugify(currentBook.title) : 'reader';
const payload = {
term: d.term,
translation: lookup.translation || lookup.translation_ru || popupTranslation.textContent,
context_sentence: d.contextSentence || '',
phonetic: lookup.phonetic || '',
audio_url: lookup.audio_url || '',
part_of_speech: lookup.part_of_speech || '',
definition_en: lookup.definition_en || '',
collection: 'Книги & Сериалы',
tags: `${bookTag}, reader`,
notes: currentBook ? `Из книги: ${currentBook.title}` : '',
};
popupAddContextBtn.disabled = true;
popupAddContextBtn.textContent = 'Сохраняем...';
try {
const resp = await fetch('/api/cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (resp.ok) {
popupAddContextBtn.classList.add('hidden');
popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!';
popupSavedBadge.classList.remove('hidden');
await fetchCards();
} else {
alert('Не удалось обновить контекст');
}
} catch (e) {
console.error('Add context error:', e);
} finally {
popupAddContextBtn.disabled = false;
popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке';
}
});
}
// Phrase/Chunk Selection in Reader
if (readerContentBody) {
readerContentBody.addEventListener('mouseup', () => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
openWordPopup(text, text, {
getBoundingClientRect: () => rect,
});
}
});
}
// Chapter Navigation & Reader Toolbar (URL Hash Driven)
if (readerChapterSelect) {
readerChapterSelect.addEventListener('change', () => {
const ch = parseInt(readerChapterSelect.value, 10);
if (currentBook) {
window.location.hash = `#reader/book/${currentBook.id}/ch/${ch}`;
}
});
}
if (readerPrevChBtn) {
readerPrevChBtn.addEventListener('click', () => {
if (currentBook && currentChapterIndex > 0) {
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex - 1}`;
}
});
}
if (readerNextChBtn) {
readerNextChBtn.addEventListener('click', () => {
if (currentBook && currentBook.chapters && currentChapterIndex < currentBook.chapters.length - 1) {
window.location.hash = `#reader/book/${currentBook.id}/ch/${currentChapterIndex + 1}`;
}
});
}
if (readerBackBtn) {
readerBackBtn.addEventListener('click', () => {
window.location.hash = '#reader';
});
}
// Scroll Position Tracking, Paragraph Anchoring & Real-Time Weighted Progress
let readerScrollSaveTimer = null;
function getReaderScrollMetrics() {
if (!readerScrollArea) return { scrollTop: 0, maxScroll: 0, pct: 0, fraction: 0 };
const scrollTop = readerScrollArea.scrollTop;
const maxScroll = Math.max(0, readerScrollArea.scrollHeight - readerScrollArea.clientHeight);
const fraction = maxScroll > 0 ? Math.min(1.0, Math.max(0, scrollTop / maxScroll)) : 0;
const pct = Math.min(100, Math.max(0, Math.round(fraction * 100)));
return { scrollTop, maxScroll, pct, fraction };
}
function getActiveAnchorIndex() {
if (!readerScrollArea || !readerContentBody) return null;
const blocks = readerContentBody.querySelectorAll('[data-block-idx]');
if (!blocks || blocks.length === 0) return null;
const containerTop = readerScrollArea.getBoundingClientRect().top;
for (let el of blocks) {
const rect = el.getBoundingClientRect();
if (rect.bottom >= containerTop + 10) {
return parseInt(el.getAttribute('data-block-idx'), 10);
}
}
return 0;
}
function updateReaderProgressBar() {
const { pct, fraction } = getReaderScrollMetrics();
if (readerChapterProgressFill) {
readerChapterProgressFill.style.width = `${pct}%`;
}
if (currentBook) {
const totalCh = Math.max(1, (currentBook.chapters ? currentBook.chapters.length : 1));
const overallFraction = (currentChapterIndex + fraction) / totalCh;
const totalPct = Math.min(100, Math.max(0, Math.round(overallFraction * 100)));
if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
readerScrollPctBadge.title = `Глава ${currentChapterIndex + 1}/${totalCh}: ${pct}% • Вся книга: ${totalPct}%`;
}
} else if (readerScrollPctBadge) {
readerScrollPctBadge.textContent = `${pct}%`;
}
}
function handleReaderScroll() {
if (!currentBook || !readerScrollArea) return;
updateReaderProgressBar();
const { scrollTop, fraction } = getReaderScrollMetrics();
const anchorIdx = getActiveAnchorIndex();
// 1. Instant local persistence
try {
const key = `notes_book_${currentBook.id}_pos`;
localStorage.setItem(key, JSON.stringify({
chapter: currentChapterIndex,
scrollTop: scrollTop,
fraction: fraction,
anchorIdx: anchorIdx,
savedAt: Date.now()
}));
localStorage.setItem('notes_active_book_id', currentBook.id);
localStorage.setItem('notes_active_view', 'reader-view');
} catch (e) {}
// 2. Debounced server sync (every 800ms)
clearTimeout(readerScrollSaveTimer);
readerScrollSaveTimer = setTimeout(() => {
if (!currentBook) return;
fetch(`/api/books/${currentBook.id}/progress`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction }),
}).catch(err => console.log('Progress save error:', err));
}, 800);
}
if (readerScrollArea) {
readerScrollArea.addEventListener('scroll', handleReaderScroll, { passive: true });
}
window.addEventListener('beforeunload', () => {
if (currentBook && readerScrollArea) {
const { scrollTop, fraction } = getReaderScrollMetrics();
const anchorIdx = getActiveAnchorIndex();
try {
localStorage.setItem(`notes_book_${currentBook.id}_pos`, JSON.stringify({
chapter: currentChapterIndex,
scrollTop: scrollTop,
fraction: fraction,
anchorIdx: anchorIdx,
savedAt: Date.now()
}));
localStorage.setItem('notes_active_book_id', currentBook.id);
} catch (e) {}
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify({ current_chapter: currentChapterIndex, current_position: fraction })], { type: 'application/json' });
navigator.sendBeacon(`/api/books/${currentBook.id}/progress`, blob);
}
}
});
function loadSavedReaderSettings() {
try {
const saved = localStorage.getItem('notes_reader_settings');
if (saved) {
readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved));
}
} catch (e) {}
}
function saveReaderSettings() {
try {
localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings));
} catch (e) {}
}
function applyReaderSettings() {
if (!readerScrollArea) return;
const anchorIdx = getActiveAnchorIndex();
// 1. Font size
readerScrollArea.style.setProperty('--reader-font-size', `${readerSettings.fontSize}px`);
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
// 2. Line height
readerScrollArea.style.setProperty('--reader-line-height', readerSettings.lineHeight);
// 3. Theme
readerScrollArea.classList.remove('reader-theme-dark', 'reader-theme-sepia', 'reader-theme-light', 'reader-theme-oled');
readerScrollArea.classList.add(`reader-theme-${readerSettings.theme}`);
// 4. Font family
readerScrollArea.classList.remove('reader-font-sans', 'reader-font-serif', 'reader-font-mono');
readerScrollArea.classList.add(`reader-font-${readerSettings.fontFamily}`);
// 5. Margin width
readerScrollArea.classList.remove('reader-margins-narrow', 'reader-margins-normal', 'reader-margins-wide');
readerScrollArea.classList.add(`reader-margins-${readerSettings.marginWidth}`);
// 6. Text align
readerScrollArea.classList.remove('reader-align-left', 'reader-align-justify');
readerScrollArea.classList.add(`reader-align-${readerSettings.textAlign}`);
updateReaderSettingsUI();
// Re-anchor scroll position after font/layout change
if (anchorIdx !== null && anchorIdx !== undefined) {
requestAnimationFrame(() => {
const el = readerContentBody.querySelector(`[data-block-idx="${anchorIdx}"]`);
if (el) {
const containerTop = readerScrollArea.getBoundingClientRect().top;
const elTop = el.getBoundingClientRect().top;
readerScrollArea.scrollTop += (elTop - containerTop - 10);
}
});
}
}
function updateReaderSettingsUI() {
if (!readerSettingsPanel) return;
if (settingFontSizeVal) settingFontSizeVal.textContent = `${readerSettings.fontSize} px`;
if (readerFontFamilyGroup) {
readerFontFamilyGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.font === readerSettings.fontFamily);
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', Math.abs(parseFloat(btn.dataset.lh) - parseFloat(readerSettings.lineHeight)) < 0.05);
});
}
if (readerMarginsGroup) {
readerMarginsGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.margin === readerSettings.marginWidth);
});
}
if (readerThemeGroup) {
readerThemeGroup.querySelectorAll('.theme-chip').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === readerSettings.theme);
});
}
if (readerAlignGroup) {
readerAlignGroup.querySelectorAll('.seg-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.align === readerSettings.textAlign);
});
}
}
function positionSettingsPanel() {
if (!readerSettingsPanel || !readerSettingsBtn) return;
if (window.innerWidth <= 768) {
readerSettingsPanel.style.top = '';
readerSettingsPanel.style.left = '';
readerSettingsPanel.style.right = '';
return;
}
const rect = readerSettingsBtn.getBoundingClientRect();
const panelWidth = 320;
const viewportWidth = window.innerWidth;
let left = rect.right - panelWidth;
if (left < 16) left = 16;
if (left + panelWidth > viewportWidth - 16) {
left = viewportWidth - panelWidth - 16;
}
readerSettingsPanel.style.top = `${Math.round(rect.bottom + 8)}px`;
readerSettingsPanel.style.left = `${Math.round(left)}px`;
readerSettingsPanel.style.right = 'auto';
}
function initReaderSettingsPanel() {
loadSavedReaderSettings();
applyReaderSettings();
if (readerSettingsBtn) {
readerSettingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (readerWordPopup) readerWordPopup.classList.add('hidden');
if (readerSettingsPanel) {
const isHidden = readerSettingsPanel.classList.contains('hidden');
readerSettingsPanel.classList.toggle('hidden', !isHidden);
if (isHidden) {
updateReaderSettingsUI();
positionSettingsPanel();
}
}
});
}
if (readerSettingsCloseBtn) {
readerSettingsCloseBtn.addEventListener('click', () => {
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
});
}
if (settingFontDecBtn) {
settingFontDecBtn.addEventListener('click', () => {
if (readerSettings.fontSize > 13) {
readerSettings.fontSize -= 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (settingFontIncBtn) {
settingFontIncBtn.addEventListener('click', () => {
if (readerSettings.fontSize < 34) {
readerSettings.fontSize += 1;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerFontFamilyGroup) {
readerFontFamilyGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.font) {
readerSettings.fontFamily = btn.dataset.font;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerLineHeightGroup) {
readerLineHeightGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.lh) {
readerSettings.lineHeight = parseFloat(btn.dataset.lh);
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerMarginsGroup) {
readerMarginsGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.margin) {
readerSettings.marginWidth = btn.dataset.margin;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerThemeGroup) {
readerThemeGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.theme-chip');
if (btn && btn.dataset.theme) {
readerSettings.theme = btn.dataset.theme;
applyReaderSettings();
saveReaderSettings();
}
});
}
if (readerAlignGroup) {
readerAlignGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (btn && btn.dataset.align) {
readerSettings.textAlign = btn.dataset.align;
applyReaderSettings();
saveReaderSettings();
}
});
}
document.addEventListener('click', (e) => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
if (!readerSettingsPanel.contains(e.target) && e.target !== readerSettingsBtn && !readerSettingsBtn.contains(e.target)) {
readerSettingsPanel.classList.add('hidden');
}
}
});
window.addEventListener('resize', () => {
if (readerSettingsPanel && !readerSettingsPanel.classList.contains('hidden')) {
positionSettingsPanel();
}
}, { passive: true });
}
initReaderSettingsPanel();
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
readerContentBody.querySelectorAll('.reader-word').forEach(w => {
w.classList.toggle('highlight-enabled', isHighlightEnabled);
});
});
}
if (readerTrainBtn) {
readerTrainBtn.addEventListener('click', () => {
if (currentBook) {
const bookTag = slugify(currentBook.title);
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
}
});
}
// Bookshelf Add/Upload Actions
if (addTextBtn) {
addTextBtn.addEventListener('click', () => {
addBookForm.reset();
addBookModal.classList.remove('hidden');
});
}
if (closeAddBookModalBtn) closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
if (cancelAddBookBtn) cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
if (addBookForm) {
addBookForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = bookTitleInput.value.trim();
const author = bookAuthorInput.value.trim();
const content = bookContentInput.value.trim();
if (!title || !content) return;
try {
const resp = await fetch('/api/books', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
author: author,
content: content,
source_type: 'text',
}),
});
if (resp.ok) {
const book = await resp.json();
addBookModal.classList.add('hidden');
window.location.hash = `#reader/book/${book.id}`;
} else {
alert('Ошибка при сохранении книги');
}
} catch (err) {
console.error('Book create error:', err);
}
});
}
if (bookFileInput) {
bookFileInput.addEventListener('change', async () => {
const file = bookFileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/books', {
method: 'POST',
body: formData,
});
if (resp.ok) {
const book = await resp.json();
bookFileInput.value = '';
window.location.hash = `#reader/book/${book.id}`;
} else {
const errData = await resp.json();
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
}
} catch (e) {
console.error('File upload error:', e);
alert('Ошибка сети при загрузке файла');
}
});
}
// -----------------------------------------------------------------------
// Utility helpers
// -----------------------------------------------------------------------
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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();
});