feat: implement Stage 5 (Interactive Reader, EPUB/TXT parser, Bookshelf, Click-to-Translate) and Stage 4.3 (Strict CEFR level calculation)
This commit is contained in:
+581
-20
@@ -45,6 +45,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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');
|
||||
@@ -55,6 +57,53 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const exportOpenBtn = document.getElementById('export-open-btn');
|
||||
const changePwdOpenBtn = document.getElementById('change-pwd-open-btn');
|
||||
|
||||
// Elements - Reader & Bookshelf
|
||||
const bookshelfContainer = document.getElementById('bookshelf-container');
|
||||
const bookshelfEmpty = document.getElementById('bookshelf-empty');
|
||||
const bookshelfGrid = document.getElementById('bookshelf-grid');
|
||||
const addTextBtn = document.getElementById('add-text-btn');
|
||||
const bookFileInput = document.getElementById('book-file-input');
|
||||
const addBookModal = document.getElementById('add-book-modal');
|
||||
const closeAddBookModalBtn = document.getElementById('close-add-book-modal-btn');
|
||||
const cancelAddBookBtn = document.getElementById('cancel-add-book-btn');
|
||||
const addBookForm = document.getElementById('add-book-form');
|
||||
const bookTitleInput = document.getElementById('book-title-input');
|
||||
const bookAuthorInput = document.getElementById('book-author-input');
|
||||
const bookContentInput = document.getElementById('book-content-input');
|
||||
|
||||
const readerInterface = document.getElementById('reader-interface');
|
||||
const readerBackBtn = document.getElementById('reader-back-btn');
|
||||
const readerBookTitle = document.getElementById('reader-book-title');
|
||||
const readerBookAuthor = document.getElementById('reader-book-author');
|
||||
const readerChapterSelect = document.getElementById('reader-chapter-select');
|
||||
const readerPrevChBtn = document.getElementById('reader-prev-ch-btn');
|
||||
const readerNextChBtn = document.getElementById('reader-next-ch-btn');
|
||||
const readerFontDecBtn = document.getElementById('reader-font-dec-btn');
|
||||
const readerFontIncBtn = document.getElementById('reader-font-inc-btn');
|
||||
const readerToggleHighlightBtn = document.getElementById('reader-toggle-highlight-btn');
|
||||
const readerTrainBtn = document.getElementById('reader-train-btn');
|
||||
const readerContentBody = document.getElementById('reader-content-body');
|
||||
const readerScrollArea = document.getElementById('reader-scroll-area');
|
||||
|
||||
const readerWordPopup = document.getElementById('reader-word-popup');
|
||||
const popupTerm = document.getElementById('popup-term');
|
||||
const popupPhonetic = document.getElementById('popup-phonetic');
|
||||
const popupAudioBtn = document.getElementById('popup-audio-btn');
|
||||
const popupCloseBtn = document.getElementById('popup-close-btn');
|
||||
const popupLoading = document.getElementById('popup-loading');
|
||||
const popupContent = document.getElementById('popup-content');
|
||||
const popupTranslation = document.getElementById('popup-translation');
|
||||
const popupContext = document.getElementById('popup-context');
|
||||
const popupSaveBtn = document.getElementById('popup-save-btn');
|
||||
const popupSavedBadge = document.getElementById('popup-saved-badge');
|
||||
|
||||
// Reader State
|
||||
let currentBook = null;
|
||||
let currentChapterIndex = 0;
|
||||
let readerFontSize = 19;
|
||||
let isHighlightEnabled = false;
|
||||
let popupCurrentData = null;
|
||||
|
||||
// Elements - Search & Capture
|
||||
const termInput = document.getElementById('term-input');
|
||||
const clearSearchBtn = document.getElementById('clear-search-btn');
|
||||
@@ -891,19 +940,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderSrsCurrentCard();
|
||||
});
|
||||
|
||||
function openSrsView() {
|
||||
dictionaryView.classList.add('hidden');
|
||||
srsView.classList.remove('hidden');
|
||||
startSrsTrainingSession(false);
|
||||
}
|
||||
|
||||
function closeSrsView() {
|
||||
function openDictionaryView() {
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
dictionaryView.classList.remove('hidden');
|
||||
loadInitialData();
|
||||
}
|
||||
|
||||
async function startSrsTrainingSession(forceAll = false) {
|
||||
function openSrsView(tagFilter = null) {
|
||||
dictionaryView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
srsView.classList.remove('hidden');
|
||||
startSrsTrainingSession(false, tagFilter);
|
||||
}
|
||||
|
||||
function closeSrsView() {
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.add('hidden');
|
||||
dictionaryView.classList.remove('hidden');
|
||||
loadInitialData();
|
||||
}
|
||||
|
||||
function openReaderView() {
|
||||
dictionaryView.classList.add('hidden');
|
||||
srsView.classList.add('hidden');
|
||||
if (readerView) readerView.classList.remove('hidden');
|
||||
loadBookshelf();
|
||||
}
|
||||
|
||||
navBrandHome.addEventListener('click', () => openDictionaryView());
|
||||
if (navReaderBtn) navReaderBtn.addEventListener('click', () => openReaderView());
|
||||
|
||||
async function startSrsTrainingSession(forceAll = false, tagFilter = null) {
|
||||
srsLoading.classList.remove('hidden');
|
||||
srsEmpty.classList.add('hidden');
|
||||
srsSummary.classList.add('hidden');
|
||||
@@ -914,9 +982,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
let queue = [];
|
||||
try {
|
||||
const url = forceAll
|
||||
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();
|
||||
@@ -1431,17 +1502,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
kpiTotalWords.textContent = s.total_cards;
|
||||
kpiTodayAdded.textContent = `Сегодня добавлено: +${s.today_added}`;
|
||||
|
||||
// CEFR Level estimation
|
||||
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;
|
||||
// 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;
|
||||
@@ -1669,6 +1744,492 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// STAGE 5: Interactive Reader & Bookshelf Implementation
|
||||
// =======================================================================
|
||||
|
||||
function slugify(text) {
|
||||
if (!text) return 'book';
|
||||
return text.toString().toLowerCase().trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
async function loadBookshelf() {
|
||||
if (!bookshelfContainer) return;
|
||||
bookshelfContainer.classList.remove('hidden');
|
||||
if (readerInterface) readerInterface.classList.add('hidden');
|
||||
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
|
||||
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/books');
|
||||
if (resp.ok) {
|
||||
const books = await resp.json();
|
||||
if (!books || books.length === 0) {
|
||||
if (bookshelfEmpty) bookshelfEmpty.classList.remove('hidden');
|
||||
} else {
|
||||
renderBookshelf(books);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading bookshelf:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookshelf(books) {
|
||||
if (!bookshelfGrid) return;
|
||||
bookshelfGrid.innerHTML = '';
|
||||
books.forEach(b => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'book-card';
|
||||
|
||||
const totalCh = b.chapters_count || 1;
|
||||
const currentCh = (b.current_chapter || 0) + 1;
|
||||
const pct = Math.min(100, Math.round((currentCh / totalCh) * 100));
|
||||
|
||||
card.innerHTML = `
|
||||
<div>
|
||||
<div class="book-card-top">
|
||||
<div class="book-cover-icon">${b.source_type === 'epub' ? '📕' : '📄'}</div>
|
||||
<div class="book-card-info">
|
||||
<h4 class="book-card-title" title="${escapeHtml(b.title)}">${escapeHtml(b.title)}</h4>
|
||||
<p class="book-card-author">${escapeHtml(b.author || 'Неизвестный автор')}</p>
|
||||
<div class="book-card-badges">
|
||||
<span class="badge-format">${escapeHtml(b.source_type)}</span>
|
||||
<span class="badge-words">${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="book-progress-wrap">
|
||||
<div class="book-progress-labels">
|
||||
<span>Глава ${currentCh} из ${totalCh}</span>
|
||||
<span>${pct}%</span>
|
||||
</div>
|
||||
<div class="book-progress-bar-bg">
|
||||
<div class="book-progress-bar-fill" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="book-card-actions">
|
||||
<button class="btn btn-primary btn-sm read-book-btn" data-id="${b.id}">📖 Читать</button>
|
||||
<button class="btn btn-outline btn-xs train-book-btn" data-title="${escapeHtml(b.title)}" title="Тренировать слова из этой книги">🎓 Слова книги</button>
|
||||
<button class="icon-btn delete-book-btn" data-id="${b.id}" title="Удалить книгу">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('.read-book-btn').addEventListener('click', () => openBook(b.id));
|
||||
card.querySelector('.train-book-btn').addEventListener('click', () => {
|
||||
const bookTag = slugify(b.title);
|
||||
openSrsView(bookTag);
|
||||
});
|
||||
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
|
||||
if (confirm(`Удалить книгу "${b.title}" из библиотеки?`)) {
|
||||
await fetch(`/api/books/${b.id}`, { method: 'DELETE' });
|
||||
loadBookshelf();
|
||||
}
|
||||
});
|
||||
|
||||
bookshelfGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function openBook(bookId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/books/${bookId}`);
|
||||
if (!resp.ok) return alert('Не удалось открыть книгу');
|
||||
currentBook = await resp.json();
|
||||
currentChapterIndex = currentBook.current_chapter || 0;
|
||||
|
||||
readerBookTitle.textContent = currentBook.title;
|
||||
readerBookAuthor.textContent = currentBook.author || '';
|
||||
|
||||
// Populate chapter selector
|
||||
readerChapterSelect.innerHTML = '';
|
||||
const chapters = currentBook.chapters || [];
|
||||
chapters.forEach((ch, idx) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = idx;
|
||||
opt.textContent = ch.title || `Глава ${idx + 1}`;
|
||||
readerChapterSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
bookshelfContainer.classList.add('hidden');
|
||||
readerInterface.classList.remove('hidden');
|
||||
|
||||
renderChapter(currentChapterIndex);
|
||||
} catch (e) {
|
||||
console.error('Error opening book:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChapter(index) {
|
||||
if (!currentBook || !currentBook.chapters) return;
|
||||
currentChapterIndex = Math.max(0, Math.min(index, currentBook.chapters.length - 1));
|
||||
readerChapterSelect.value = currentChapterIndex;
|
||||
|
||||
readerPrevChBtn.disabled = currentChapterIndex <= 0;
|
||||
readerNextChBtn.disabled = currentChapterIndex >= currentBook.chapters.length - 1;
|
||||
|
||||
const chapter = currentBook.chapters[currentChapterIndex] || { title: '', content: '' };
|
||||
readerContentBody.innerHTML = '';
|
||||
|
||||
if (chapter.title) {
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = chapter.title;
|
||||
readerContentBody.appendChild(h);
|
||||
}
|
||||
|
||||
// Map existing user vocabulary for status marking
|
||||
const knownWordsMap = new Map();
|
||||
allCards.forEach(c => {
|
||||
if (c.term) knownWordsMap.set(c.term.trim().toLowerCase(), c);
|
||||
});
|
||||
|
||||
// Split content into paragraphs
|
||||
const paragraphs = chapter.content.split(/\n\s*\n/);
|
||||
paragraphs.forEach(paraText => {
|
||||
paraText = paraText.trim();
|
||||
if (!paraText) return;
|
||||
|
||||
const p = document.createElement('p');
|
||||
// Tokenize words while preserving spaces & punctuation
|
||||
const tokens = paraText.split(/([A-Za-z0-9'-]+)/);
|
||||
tokens.forEach(token => {
|
||||
if (/^[A-Za-z0-9'-]+$/.test(token) && token.length > 1) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'reader-word';
|
||||
span.dataset.word = token;
|
||||
span.textContent = token;
|
||||
|
||||
const lower = token.toLowerCase();
|
||||
if (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, paraText, span);
|
||||
});
|
||||
|
||||
p.appendChild(span);
|
||||
} else {
|
||||
p.appendChild(document.createTextNode(token));
|
||||
}
|
||||
});
|
||||
|
||||
readerContentBody.appendChild(p);
|
||||
});
|
||||
|
||||
if (readerScrollArea) readerScrollArea.scrollTop = 0;
|
||||
|
||||
// Save progress to server
|
||||
fetch(`/api/books/${currentBook.id}/progress`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_chapter: currentChapterIndex, current_position: 0 }),
|
||||
}).catch(err => console.log('Progress save error:', err));
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
async function openWordPopup(term, contextSentence, targetElement) {
|
||||
popupCurrentData = {
|
||||
term: term,
|
||||
contextSentence: contextSentence,
|
||||
targetElement: targetElement,
|
||||
};
|
||||
|
||||
popupTerm.textContent = term;
|
||||
popupPhonetic.textContent = '';
|
||||
popupTranslation.textContent = '';
|
||||
popupContext.textContent = contextSentence ? `“${contextSentence}”` : '';
|
||||
popupLoading.classList.remove('hidden');
|
||||
popupContent.classList.add('hidden');
|
||||
popupSavedBadge.classList.add('hidden');
|
||||
popupSaveBtn.classList.remove('hidden');
|
||||
popupSaveBtn.disabled = false;
|
||||
|
||||
// Position popup near target element
|
||||
readerWordPopup.classList.remove('hidden');
|
||||
if (targetElement) {
|
||||
const rect = targetElement.getBoundingClientRect();
|
||||
let top = rect.bottom + window.scrollY + 6;
|
||||
let left = rect.left + window.scrollX;
|
||||
|
||||
const popupWidth = 320;
|
||||
if (left + popupWidth > window.innerWidth - 16) {
|
||||
left = window.innerWidth - popupWidth - 16;
|
||||
}
|
||||
if (left < 16) left = 16;
|
||||
|
||||
readerWordPopup.style.top = `${top}px`;
|
||||
readerWordPopup.style.left = `${left}px`;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/lookup?q=${encodeURIComponent(term)}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
popupCurrentData.lookup = data;
|
||||
|
||||
popupPhonetic.textContent = data.phonetic || '';
|
||||
popupTranslation.textContent = data.translation_ru || 'Перевод не найден';
|
||||
popupLoading.classList.add('hidden');
|
||||
popupContent.classList.remove('hidden');
|
||||
|
||||
if (data.audio_url) {
|
||||
popupAudioBtn.classList.remove('hidden');
|
||||
popupAudioBtn.onclick = () => playAudio(data.audio_url);
|
||||
} else {
|
||||
popupAudioBtn.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
popupTranslation.textContent = 'Ошибка перевода';
|
||||
popupLoading.classList.add('hidden');
|
||||
popupContent.classList.remove('hidden');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Popup lookup error:', e);
|
||||
popupTranslation.textContent = 'Сетевая ошибка';
|
||||
popupLoading.classList.add('hidden');
|
||||
popupContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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_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) {
|
||||
popupSaveBtn.classList.add('hidden');
|
||||
popupSavedBadge.classList.remove('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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Phrase/Chunk Selection in Reader
|
||||
if (readerContentBody) {
|
||||
readerContentBody.addEventListener('mouseup', () => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel.toString().trim();
|
||||
if (text && text.split(/\s+/).length >= 2 && text.length <= 80) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
openWordPopup(text, text, {
|
||||
getBoundingClientRect: () => rect,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chapter Navigation & Reader Toolbar
|
||||
if (readerChapterSelect) {
|
||||
readerChapterSelect.addEventListener('change', () => {
|
||||
renderChapter(parseInt(readerChapterSelect.value, 10));
|
||||
});
|
||||
}
|
||||
|
||||
if (readerPrevChBtn) {
|
||||
readerPrevChBtn.addEventListener('click', () => {
|
||||
if (currentChapterIndex > 0) renderChapter(currentChapterIndex - 1);
|
||||
});
|
||||
}
|
||||
|
||||
if (readerNextChBtn) {
|
||||
readerNextChBtn.addEventListener('click', () => {
|
||||
if (currentBook && currentChapterIndex < currentBook.chapters.length - 1) {
|
||||
renderChapter(currentChapterIndex + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerBackBtn) {
|
||||
readerBackBtn.addEventListener('click', () => {
|
||||
readerInterface.classList.add('hidden');
|
||||
bookshelfContainer.classList.remove('hidden');
|
||||
loadBookshelf();
|
||||
});
|
||||
}
|
||||
|
||||
if (readerFontIncBtn) {
|
||||
readerFontIncBtn.addEventListener('click', () => {
|
||||
if (readerFontSize < 28) {
|
||||
readerFontSize += 2;
|
||||
readerContentBody.style.fontSize = `${readerFontSize}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerFontDecBtn) {
|
||||
readerFontDecBtn.addEventListener('click', () => {
|
||||
if (readerFontSize > 14) {
|
||||
readerFontSize -= 2;
|
||||
readerContentBody.style.fontSize = `${readerFontSize}px`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (readerToggleHighlightBtn) {
|
||||
readerToggleHighlightBtn.addEventListener('click', () => {
|
||||
isHighlightEnabled = !isHighlightEnabled;
|
||||
readerToggleHighlightBtn.classList.toggle('btn-primary', isHighlightEnabled);
|
||||
readerContentBody.querySelectorAll('.reader-word').forEach(w => {
|
||||
w.classList.toggle('highlight-enabled', isHighlightEnabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (readerTrainBtn) {
|
||||
readerTrainBtn.addEventListener('click', () => {
|
||||
if (currentBook) {
|
||||
const bookTag = slugify(currentBook.title);
|
||||
openSrsView(bookTag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bookshelf Add/Upload Actions
|
||||
if (addTextBtn) {
|
||||
addTextBtn.addEventListener('click', () => {
|
||||
addBookForm.reset();
|
||||
addBookModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (closeAddBookModalBtn) closeAddBookModalBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
|
||||
if (cancelAddBookBtn) cancelAddBookBtn.addEventListener('click', () => addBookModal.classList.add('hidden'));
|
||||
|
||||
if (addBookForm) {
|
||||
addBookForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const title = bookTitleInput.value.trim();
|
||||
const author = bookAuthorInput.value.trim();
|
||||
const content = bookContentInput.value.trim();
|
||||
|
||||
if (!title || !content) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/books', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title,
|
||||
author: author,
|
||||
content: content,
|
||||
source_type: 'text',
|
||||
}),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const book = await resp.json();
|
||||
addBookModal.classList.add('hidden');
|
||||
await loadBookshelf();
|
||||
openBook(book.id);
|
||||
} else {
|
||||
alert('Ошибка при сохранении книги');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Book create error:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bookFileInput) {
|
||||
bookFileInput.addEventListener('change', async () => {
|
||||
const file = bookFileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/books', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
const book = await resp.json();
|
||||
bookFileInput.value = '';
|
||||
await loadBookshelf();
|
||||
openBook(book.id);
|
||||
} else {
|
||||
const errData = await resp.json();
|
||||
alert(`Ошибка загрузки: ${errData.detail || 'Не удалось обработать файл'}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('File upload error:', e);
|
||||
alert('Ошибка сети при загрузке файла');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Utility helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user