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