// ======================================================================= // Notes Application - Reader & Bookshelf Module (Native ES Module) // ======================================================================= import { api } from './api.js'; import { escapeHtml, escapeRegex, slugify, showToast } from './ui.js'; import { getAllCards, fetchCards } from './dictionary.js'; // 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; let readerScrollSaveTimer = null; let popupScrollRaf = null; export function getCurrentBook() { return currentBook; } export function getCurrentChapterIndex() { return currentChapterIndex; } // ----------------------------------------------------------------------- // Bookshelf Actions & Rendering // ----------------------------------------------------------------------- export async function loadBookshelf() { const bookshelfContainer = document.getElementById('bookshelf-container'); const readerInterface = document.getElementById('reader-interface'); const bookshelfGrid = document.getElementById('bookshelf-grid'); const bookshelfEmpty = document.getElementById('bookshelf-empty'); 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 books = await api.books.getAll(); if (!books || books.length === 0) { if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden'); } else { renderBookshelf(books); } } catch (e) { console.error('Error loading bookshelf:', e); } } export function renderBookshelf(books) { const bookshelfGrid = document.getElementById('bookshelf-grid'); 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 = `
or
]*>/i, '').replace(/<\/p>$/i, '');
processed = processed.replace(/<\/?(?:picture|source)[^>]*>/gi, '');
// Convert Markdown inline patterns into HTML
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '');
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
processed = processed.replace(/`([^`]+)`/g, '
$1');
processed = processed.replace(/\*\*([^*]+)\*\*/g, '$1');
processed = processed.replace(/__([^_]+)__/g, '$1');
processed = processed.replace(/(?$1');
processed = processed.replace(/(?$1');
const tpl = document.createElement('template');
tpl.innerHTML = processed;
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.dataset.context = contextText || raw;
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');
}
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(token));
}
}
return frag;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
if (tag === 'code' || tag === 'pre') {
const codeEl = document.createElement('code');
codeEl.className = 'reader-inline-code';
codeEl.textContent = node.textContent;
return codeEl;
}
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'));
img.addEventListener('error', () => {
img.style.display = 'none';
});
return img;
}
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;
}
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(tpl.content.childNodes).forEach(childNode => {
container.appendChild(transformDOMNode(childNode));
});
}
export 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();
}
// -----------------------------------------------------------------------
// Interactive Word Popup
// -----------------------------------------------------------------------
export function positionWordPopup(targetElement) {
const readerWordPopup = document.getElementById('reader-word-popup');
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;
let left = rect.left + (rect.width / 2) - (popupWidth / 2);
if (left + popupWidth > viewportWidth - 12) {
left = viewportWidth - popupWidth - 12;
}
if (left < 12) {
left = 12;
}
let top = rect.bottom + 8;
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`;
}
export async function openWordPopup(term, contextSentence, targetElement) {
const readerWordPopup = document.getElementById('reader-word-popup');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const popupTerm = document.getElementById('popup-term');
const popupPhonetic = document.getElementById('popup-phonetic');
const popupTranslation = document.getElementById('popup-translation');
const popupContext = document.getElementById('popup-context');
const popupLoading = document.getElementById('popup-loading');
const popupContent = document.getElementById('popup-content');
const popupInLibrary = document.getElementById('popup-in-library');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupAudioBtn = document.getElementById('popup-audio-btn');
const cleanTerm = (term || '').replace(/^['"“«`\s\-_.,;:!?()[\]{}]+|['"”»`\s\-_.,;:!?()[\]{}]+$/g, '').trim();
if (!cleanTerm || !readerWordPopup) return;
if (readerSettingsPanel) readerSettingsPanel.classList.add('hidden');
const lowerClean = cleanTerm.toLowerCase();
const allCards = getAllCards() || [];
const existingCard = allCards.find(c => c.term && c.term.trim().toLowerCase() === lowerClean);
popupCurrentData = {
term: cleanTerm,
contextSentence: contextSentence,
targetElement: targetElement,
existingCard: existingCard,
};
if (popupTerm) popupTerm.textContent = cleanTerm;
if (popupPhonetic) popupPhonetic.textContent = '';
if (popupTranslation) popupTranslation.textContent = '';
if (popupContext) popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
if (popupLoading) popupLoading.classList.remove('hidden');
if (popupContent) popupContent.classList.add('hidden');
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupSentenceAiBar = document.querySelector('.popup-sentence-ai-bar');
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = '';
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = '';
if (popupAiSourceBadge) popupAiSourceBadge.classList.add('hidden');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.disabled = false;
popupTranslateSentenceBtn.textContent = '🌐 Перевести предложение';
if (popupSentenceAiBar) popupSentenceAiBar.classList.toggle('hidden', !contextSentence);
}
if (popupInLibrary) {
popupInLibrary.classList.toggle('hidden', !existingCard);
}
if (existingCard) {
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && (!existingCard.context_sentence || !existingCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupSaveBtn) {
popupSaveBtn.classList.remove('hidden');
popupSaveBtn.disabled = false;
}
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
readerWordPopup.classList.remove('hidden');
positionWordPopup(targetElement);
try {
const data = await api.lookup.search(cleanTerm);
popupCurrentData.lookup = data;
const trans = data.translation || data.translation_ru || (data.translations_list && data.translations_list.length > 0 ? data.translations_list.join(', ') : '');
if (popupPhonetic) popupPhonetic.textContent = data.phonetic || '';
if (popupTranslation) popupTranslation.textContent = trans || 'Перевод не найден';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
if (popupAudioBtn) 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');
if (popupSaveBtn) popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.classList.remove('hidden');
popupSavedBadge.textContent = '⭐ Слово уже в библиотеке';
}
if (popupAddContextBtn) {
if (contextSentence && libCard && (!libCard.context_sentence || !libCard.context_sentence.includes(contextSentence))) {
popupAddContextBtn.classList.remove('hidden');
} else {
popupAddContextBtn.classList.add('hidden');
}
}
} else {
if (popupInLibrary) popupInLibrary.classList.add('hidden');
if (popupSaveBtn) popupSaveBtn.classList.remove('hidden');
if (popupSavedBadge) popupSavedBadge.classList.add('hidden');
if (popupAddContextBtn) popupAddContextBtn.classList.add('hidden');
}
positionWordPopup(targetElement);
} catch (e) {
console.error('Popup lookup error:', e);
if (popupTranslation) popupTranslation.textContent = 'Сетевая ошибка';
if (popupLoading) popupLoading.classList.add('hidden');
if (popupContent) popupContent.classList.remove('hidden');
positionWordPopup(targetElement);
}
}
export async function handleWordClick(word, paragraphText, targetElement) {
const sentence = extractContextSentence(paragraphText, word);
openWordPopup(word, sentence, targetElement);
}
function updatePopupPositionOnScroll() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (window.innerWidth <= 768) return;
if (!popupCurrentData || !popupCurrentData.targetElement || !popupCurrentData.targetElement.isConnected) {
readerWordPopup.classList.add('hidden');
return;
}
const rect = popupCurrentData.targetElement.getBoundingClientRect();
const headerOffset = 65;
if (rect.bottom < headerOffset || rect.top > window.innerHeight) {
readerWordPopup.classList.add('hidden');
return;
}
positionWordPopup(popupCurrentData.targetElement);
}
function schedulePopupUpdate() {
const readerWordPopup = document.getElementById('reader-word-popup');
if (!readerWordPopup || readerWordPopup.classList.contains('hidden')) return;
if (popupScrollRaf) return;
popupScrollRaf = requestAnimationFrame(() => {
popupScrollRaf = null;
updatePopupPositionOnScroll();
});
}
// -----------------------------------------------------------------------
// Scroll Position Tracking & Metrics
// -----------------------------------------------------------------------
export function getReaderScrollMetrics() {
const readerScrollArea = document.getElementById('reader-scroll-area');
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 };
}
export function getActiveAnchorIndex() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
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;
}
export function getActiveVisibleChapter() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerContentBody = document.getElementById('reader-content-body');
if (!readerScrollArea || !readerContentBody) return currentChapterIndex;
const anchors = readerContentBody.querySelectorAll('[id^="chapter-anchor-"]');
if (!anchors || anchors.length === 0) return currentChapterIndex;
const containerTop = readerScrollArea.getBoundingClientRect().top;
let activeIdx = 0;
for (let el of anchors) {
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;
}
export function updateReaderProgressBar() {
const readerChapterProgressFill = document.getElementById('reader-chapter-progress-fill');
const readerScrollPctBadge = document.getElementById('reader-scroll-pct-badge');
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;
}
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}%`;
}
}
export function handleReaderScroll() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
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;
updateChapterSelectIcons();
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 with autoload
if (readerSettings.flowMode === 'chapters' && readerSettings.autoLoadNext && !isAppendingChapter) {
const distanceToBottom = readerScrollArea.scrollHeight - (readerScrollArea.scrollTop + readerScrollArea.clientHeight);
if (distanceToBottom < 700) {
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; }, 200);
}
}
}
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;
api.books.saveProgress(currentBook.id, { current_chapter: currentChapterIndex, current_position: fraction })
.catch(err => console.log('Progress save error:', err));
}, 800);
}
// -----------------------------------------------------------------------
// Reader Appearance & Settings
// -----------------------------------------------------------------------
export function loadSavedReaderSettings() {
try {
const saved = localStorage.getItem('notes_reader_settings');
if (saved) {
readerSettings = Object.assign({}, DEFAULT_READER_SETTINGS, JSON.parse(saved));
}
} catch (e) {}
}
export function saveReaderSettings() {
try {
localStorage.setItem('notes_reader_settings', JSON.stringify(readerSettings));
} catch (e) {}
}
export function applyReaderSettings() {
const readerScrollArea = document.getElementById('reader-scroll-area');
const settingFontSizeVal = document.getElementById('setting-font-size-val');
const readerContentBody = document.getElementById('reader-content-body');
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 && readerContentBody) {
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);
}
});
}
}
export function updateReaderSettingsUI() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
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');
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);
});
}
}
export function positionSettingsPanel() {
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsBtn = document.getElementById('reader-settings-btn');
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`;
}
// -----------------------------------------------------------------------
// Reader Module Initialization
// -----------------------------------------------------------------------
export function initReader() {
loadSavedReaderSettings();
applyReaderSettings();
const readerSettingsBtn = document.getElementById('reader-settings-btn');
const readerSettingsPanel = document.getElementById('reader-settings-panel');
const readerSettingsCloseBtn = document.getElementById('reader-settings-close-btn');
const readerWordPopup = document.getElementById('reader-word-popup');
const settingFontDecBtn = document.getElementById('setting-font-dec-btn');
const settingFontIncBtn = document.getElementById('setting-font-inc-btn');
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 readerContentBody = document.getElementById('reader-content-body');
const readerScrollArea = document.getElementById('reader-scroll-area');
const readerChapterSelect = document.getElementById('reader-chapter-select');
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
const readerBackBtn = document.getElementById('reader-back-btn');
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
const readerTrainBtn = document.getElementById('reader-train-btn');
const popupCloseBtn = document.getElementById('popup-close-btn');
const popupSaveBtn = document.getElementById('popup-save-btn');
const popupSavedBadge = document.getElementById('popup-saved-badge');
const popupInLibrary = document.getElementById('popup-in-library');
const popupAddContextBtn = document.getElementById('popup-add-context-btn');
const popupTranslation = document.getElementById('popup-translation');
const addTextBtn = document.getElementById('add-text-btn');
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 bookFileInput = document.getElementById('book-file-input');
// Settings panel triggers
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) {
if (readerSettings.flowMode === 'continuous') {
renderContinuousBook(currentChapterIndex);
} else {
renderChapter(currentChapterIndex);
}
}
}
}
});
}
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 ? readerContentBody.querySelector('.chapter-footer-hint') : null;
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 });
// Word clicking & highlighting
if (readerContentBody) {
readerContentBody.addEventListener('click', (e) => {
const wordSpan = e.target.closest('.reader-word');
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
// Phrase/chunk selection in reader
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,
});
}
});
}
if (readerToggleHighlightBtn) {
readerToggleHighlightBtn.addEventListener('click', () => {
isHighlightEnabled = !isHighlightEnabled;
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
if (readerContentBody) {
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)}`;
}
});
}
// Popup actions
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', () => {
if (readerWordPopup) readerWordPopup.classList.add('hidden');
});
}
document.addEventListener('click', (e) => {
if (readerWordPopup && !readerWordPopup.contains(e.target) && !e.target.classList.contains('reader-word')) {
readerWordPopup.classList.add('hidden');
}
});
window.addEventListener('scroll', schedulePopupUpdate, { passive: true, capture: true });
window.addEventListener('resize', schedulePopupUpdate, { passive: true });
const popupTranslateSentenceBtn = document.getElementById('popup-translate-sentence-btn');
const popupUseContextMeaningBtn = document.getElementById('popup-use-context-meaning-btn');
const popupSentenceLoading = document.getElementById('popup-sentence-loading');
const popupSentenceTranslationWrap = document.getElementById('popup-sentence-translation-wrap');
const popupSentenceRu = document.getElementById('popup-sentence-ru');
const popupContextMeaningWrap = document.getElementById('popup-context-meaning-wrap');
const popupContextMeaningVal = document.getElementById('popup-context-meaning-val');
const popupAiSourceBadge = document.getElementById('popup-ai-source-badge');
if (popupTranslateSentenceBtn) {
popupTranslateSentenceBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextSentence) return;
const ctxSentence = popupCurrentData.contextSentence;
const term = popupCurrentData.term;
popupTranslateSentenceBtn.disabled = true;
if (popupSentenceLoading) popupSentenceLoading.classList.remove('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.add('hidden');
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
try {
const res = await api.lookup.translateSentence(ctxSentence, term);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) {
popupSentenceRu.textContent = res.sentence_ru || 'Перевод не получен';
}
if (res.word_meaning && res.word_meaning.trim()) {
popupCurrentData.contextMeaning = res.word_meaning.trim();
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.remove('hidden');
if (popupContextMeaningVal) popupContextMeaningVal.textContent = res.word_meaning.trim();
} else {
if (popupContextMeaningWrap) popupContextMeaningWrap.classList.add('hidden');
}
if (popupAiSourceBadge) {
popupAiSourceBadge.classList.remove('hidden');
popupAiSourceBadge.textContent = res.cached ? '⚡ Кеш (0.01ms)' : '⚡ Local AI (Qwen)';
popupAiSourceBadge.title = res.source || '';
}
positionWordPopup(popupCurrentData.targetElement);
} catch (err) {
console.error('Sentence translation error:', err);
if (popupSentenceLoading) popupSentenceLoading.classList.add('hidden');
if (popupSentenceTranslationWrap) popupSentenceTranslationWrap.classList.remove('hidden');
if (popupSentenceRu) popupSentenceRu.textContent = 'Ошибка связи с локальным ИИ';
} finally {
popupTranslateSentenceBtn.disabled = false;
}
});
}
if (popupUseContextMeaningBtn) {
popupUseContextMeaningBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!popupCurrentData || !popupCurrentData.contextMeaning) return;
const meaning = popupCurrentData.contextMeaning;
if (popupTranslation) popupTranslation.textContent = meaning;
if (popupCurrentData.lookup) {
popupCurrentData.lookup.translation = meaning;
}
popupUseContextMeaningBtn.textContent = '✓ Вставлено!';
setTimeout(() => {
if (popupUseContextMeaningBtn) popupUseContextMeaningBtn.textContent = 'Вставить в карточку';
}, 1800);
});
}
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 ? 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 api.cards.create(payload);
if (resp.ok) {
const resData = await resp.json();
popupSaveBtn.classList.add('hidden');
if (popupSavedBadge) {
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();
} 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 ? 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 api.cards.create(payload);
if (resp.ok) {
popupAddContextBtn.classList.add('hidden');
if (popupSavedBadge) {
popupSavedBadge.textContent = '✅ Цитата прикреплена к карточке!';
popupSavedBadge.classList.remove('hidden');
}
await fetchCards();
} else {
alert('Не удалось обновить контекст');
}
} catch (e) {
console.error('Add context error:', e);
} finally {
popupAddContextBtn.disabled = false;
popupAddContextBtn.textContent = '+ Добавить эту цитату к карточке';
}
});
}
// Chapter Navigation & Reader Toolbar
if (readerChapterSelect) {
readerChapterSelect.addEventListener('change', () => {
const ch = parseInt(readerChapterSelect.value, 10);
if (!currentBook) return;
if (readerSettings.flowMode === 'continuous') {
currentChapterIndex = ch;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${ch}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${ch}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(ch);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${ch}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
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') {
currentChapterIndex = prev;
if (readerChapterSelect) readerChapterSelect.value = prev;
updateChapterSelectIcons();
readerPrevChBtn.disabled = currentChapterIndex <= 0;
if (readerNextChBtn) readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${prev}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${prev}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(prev);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${prev}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
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') {
currentChapterIndex = next;
if (readerChapterSelect) readerChapterSelect.value = next;
updateChapterSelectIcons();
if (readerPrevChBtn) readerPrevChBtn.disabled = currentChapterIndex <= 0;
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
const target = document.getElementById(`chapter-anchor-${next}`)
|| (readerContentBody && readerContentBody.querySelector(`[data-chapter-idx="${next}"]`));
if (target) {
scrollToElement(target, true);
} else {
renderContinuousBook(next);
}
const desiredHash = `#reader/book/${currentBook.id}/ch/${next}`;
if (window.location.hash !== desiredHash) {
history.replaceState(null, '', desiredHash);
}
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';
});
}
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);
}
}
});
// Bookshelf Add/Upload Actions
if (addTextBtn && addBookModal && addBookForm) {
addTextBtn.addEventListener('click', () => {
addBookForm.reset();
addBookModal.classList.remove('hidden');
});
}
if (closeAddBookModalBtn && addBookModal) {
closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (cancelAddBookBtn && addBookModal) {
cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
}
if (addBookForm) {
addBookForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = bookTitleInput ? bookTitleInput.value.trim() : '';
const author = bookAuthorInput ? bookAuthorInput.value.trim() : '';
const content = bookContentInput ? bookContentInput.value.trim() : '';
if (!title || !content) return;
try {
const resp = await api.books.createManual({
title: title,
author: author,
content: content,
source_type: 'text',
});
if (resp.ok) {
const book = await resp.json();
if (addBookModal) 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 api.books.uploadFile(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('Ошибка сети или соединение прервано при загрузке файла');
}
});
}
}