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:
Antigravity committed 2026-10-05 11:42:13 +03:00
1 parent c51c505b61
commit d8a2c6e34f
9 files changed
+2101 -30

No files matched your search

+382
View File
@@ -2396,4 +2396,386 @@ body {
}
}
/* ==========================================================================
Stage 5: Interactive Reader & Bookshelf Styles
========================================================================== */
.reader-view {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 80px;
}
.bookshelf-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.bookshelf-title-wrap h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-main);
display: flex;
align-items: center;
gap: 8px;
}
.bookshelf-title-wrap .subtitle {
font-size: 14px;
color: var(--text-muted);
margin-top: 4px;
}
.bookshelf-actions {
display: flex;
align-items: center;
gap: 12px;
}
.file-upload-label {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.bookshelf-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.book-card {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition);
position: relative;
box-shadow: var(--shadow-sm);
}
.book-card:hover {
border-color: var(--border-focus);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.book-card-top {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
}
.book-cover-icon {
font-size: 32px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
width: 52px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.book-card-info {
flex: 1;
min-width: 0;
}
.book-card-title {
font-size: 16px;
font-weight: 700;
color: var(--text-main);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.book-card-author {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.book-card-badges {
display: flex;
align-items: center;
gap: 8px;
}
.badge-format {
text-transform: uppercase;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--tag-bg);
color: var(--tag-text);
}
.badge-words {
font-size: 12px;
color: var(--text-dim);
}
.book-progress-wrap {
margin: 14px 0 16px;
}
.book-progress-labels {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 6px;
}
.book-progress-bar-bg {
width: 100%;
height: 6px;
background-color: var(--bg-input);
border-radius: var(--radius-full);
overflow: hidden;
}
.book-progress-bar-fill {
height: 100%;
background-color: var(--primary);
border-radius: var(--radius-full);
transition: width 0.3s ease;
}
.book-card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
/* Reader Interface */
.reader-interface {
background-color: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.reader-toolbar {
position: sticky;
top: 0;
z-index: 100;
background-color: var(--bg-surface);
border-bottom: 1px solid var(--border-color);
padding: 12px 18px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.reader-toolbar-left {
display: flex;
align-items: center;
gap: 14px;
}
.reader-meta {
display: flex;
flex-direction: column;
}
.reader-title {
font-size: 16px;
font-weight: 700;
color: var(--text-main);
line-height: 1.2;
}
.reader-author {
font-size: 12px;
color: var(--text-muted);
}
.reader-toolbar-center {
display: flex;
align-items: center;
gap: 8px;
}
.reader-select {
padding: 6px 12px;
border-radius: var(--radius-sm);
background-color: var(--bg-input);
color: var(--text-main);
border: 1px solid var(--border-color);
font-size: 13px;
outline: none;
max-width: 240px;
}
.reader-toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.font-controls {
display: flex;
align-items: center;
gap: 4px;
}
.reader-scroll-area {
padding: 36px 24px 60px;
max-width: 780px;
margin: 0 auto;
min-height: 60vh;
}
.reader-content-body {
font-family: Georgia, 'Times New Roman', serif;
font-size: 19px;
line-height: 1.85;
color: var(--text-main);
word-break: break-word;
user-select: text;
}
.reader-content-body h1,
.reader-content-body h2,
.reader-content-body h3 {
font-family: var(--font-family);
margin: 28px 0 16px;
color: var(--text-main);
}
.reader-content-body p {
margin-bottom: 20px;
text-indent: 1.2em;
}
/* Interactive Words */
.reader-word {
cursor: pointer;
border-radius: 3px;
transition: background-color 0.12s ease, color 0.12s ease;
padding: 1px 2px;
}
.reader-word:hover {
background-color: var(--primary-dim);
color: var(--primary);
}
.reader-word.status-mastered.highlight-enabled {
border-bottom: 2px dotted rgba(16, 185, 129, 0.5);
}
.reader-word.status-learning.highlight-enabled {
border-bottom: 2px dotted rgba(56, 189, 248, 0.5);
}
/* Click-to-Translate Popup */
.reader-word-popup {
position: fixed;
z-index: 10001;
background-color: var(--bg-surface);
border: 1px solid var(--border-focus);
border-radius: var(--radius-lg);
box-shadow: 0 12px 24px -4px rgba(0, 0, 0, 0.5);
width: 320px;
max-width: calc(100vw - 32px);
padding: 16px;
animation: fadeInScale 0.15s ease-out;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
}
.popup-term-wrap {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.popup-term {
font-size: 18px;
color: var(--text-main);
}
.popup-phonetic {
font-size: 13px;
color: var(--primary);
font-family: monospace;
}
.popup-close-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
padding: 4px;
}
.popup-close-btn:hover {
color: var(--text-main);
}
.popup-translation {
font-size: 15px;
font-weight: 600;
color: var(--success);
margin-bottom: 8px;
}
.popup-context {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
background: var(--bg-input);
padding: 8px 10px;
border-radius: var(--radius-sm);
margin-bottom: 14px;
line-height: 1.4;
border-left: 3px solid var(--primary);
}
.popup-saved-badge {
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--success);
padding: 6px;
}
+116
View File
@@ -67,6 +67,9 @@
</div>
<div class="nav-actions">
<button id="nav-reader-btn" class="btn btn-outline btn-sm" title="Интерактивная читалка и библиотека книг">
<span>📚 Читалка</span>
</button>
<button id="srs-trainer-btn" class="btn btn-trainer" title="Интервальный тренажер (Flashcards, 4 варианта, диктант, ввод)">
<span class="trainer-icon">🎓</span>
<span class="trainer-text">Тренажёр SRS</span>
@@ -517,6 +520,92 @@
</div>
</section>
<!-- VIEW 3: Interactive Reader & Bookshelf View -->
<section id="reader-view" class="reader-view hidden">
<!-- Bookshelf View -->
<div id="bookshelf-container" class="bookshelf-container">
<div class="bookshelf-header">
<div class="bookshelf-title-wrap">
<h2>📚 Библиотека & Интерактивная Читалка</h2>
<p class="subtitle">Читайте тексты и книги с мгновенным клик-переводом, озвучкой и авто-захватом контекста</p>
</div>
<div class="bookshelf-actions">
<button id="add-text-btn" class="btn btn-outline">✏️ Вставить текст</button>
<label for="book-file-input" class="btn btn-primary file-upload-label">
📁 Загрузить EPUB / TXT
<input type="file" id="book-file-input" accept=".epub,.txt,.md" style="display: none;">
</label>
</div>
</div>
<!-- Bookshelf Grid / Empty -->
<div id="bookshelf-empty" class="empty-state hidden">
<div class="empty-icon">📖</div>
<h3>Ваша библиотека пуста</h3>
<p>Загрузите книгу в формате EPUB, текстовый файл TXT/MD или вставьте текст статьи, чтобы начать интерактивное чтение.</p>
</div>
<div id="bookshelf-grid" class="bookshelf-grid"></div>
</div>
<!-- Reading Interface (Active when a book is open) -->
<div id="reader-interface" class="reader-interface hidden">
<header class="reader-toolbar">
<div class="reader-toolbar-left">
<button id="reader-back-btn" class="btn btn-sm btn-outline">← Библиотека</button>
<div class="reader-meta">
<h3 id="reader-book-title" class="reader-title">Книга</h3>
<span id="reader-book-author" class="reader-author">Автор</span>
</div>
</div>
<div class="reader-toolbar-center">
<button id="reader-prev-ch-btn" class="btn btn-xs btn-outline" title="Предыдущая глава">◀</button>
<select id="reader-chapter-select" class="reader-select"></select>
<button id="reader-next-ch-btn" class="btn btn-xs btn-outline" title="Следующая глава">▶</button>
</div>
<div class="reader-toolbar-right">
<div class="font-controls">
<button id="reader-font-dec-btn" class="btn btn-xs btn-outline" title="Уменьшить шрифт">A-</button>
<button id="reader-font-inc-btn" class="btn btn-xs btn-outline" title="Увеличить шрифт">A+</button>
</div>
<button id="reader-toggle-highlight-btn" class="btn btn-xs btn-outline" title="Вкл/Выкл подсветку изучаемых слов">✨ Подсветка</button>
<button id="reader-train-btn" class="btn btn-xs btn-trainer" title="Тренировать слова из этой книги">🎓 Слова книги</button>
</div>
</header>
<!-- Reading Container -->
<div class="reader-scroll-area" id="reader-scroll-area">
<div class="reader-content-body" id="reader-content-body">
<!-- Interactive paragraphs dynamically rendered here -->
</div>
</div>
</div>
<!-- Interactive Click-to-Translate Popup -->
<div id="reader-word-popup" class="reader-word-popup hidden">
<div class="popup-header">
<div class="popup-term-wrap">
<strong id="popup-term" class="popup-term">word</strong>
<span id="popup-phonetic" class="popup-phonetic">/wɜːd/</span>
<button id="popup-audio-btn" class="icon-btn audio-btn" title="Слушать произношение">🔊</button>
</div>
<button id="popup-close-btn" class="popup-close-btn">✕</button>
</div>
<div class="popup-body">
<div id="popup-loading" class="popup-loading hidden">Переводим...</div>
<div id="popup-content" class="popup-content">
<div class="popup-translation" id="popup-translation">перевод</div>
<div class="popup-context" id="popup-context">“Контекст из книги...”</div>
</div>
</div>
<div class="popup-footer">
<button id="popup-save-btn" class="btn btn-primary btn-sm btn-block">💾 Добавить в словарь</button>
<div id="popup-saved-badge" class="popup-saved-badge hidden">✅ Сохранено в словарь!</div>
</div>
</div>
</section>
<!-- Sticky Bulk Action Bar for Batch Selection Mode -->
<div id="batch-action-bar" class="batch-action-bar hidden">
<div class="batch-left">
@@ -800,6 +889,33 @@
<button type="submit" id="change-pwd-save-btn" class="btn btn-primary">Сохранить</button>
</div>
</form>
<!-- Add Book Modal (Paste Text) -->
<div id="add-book-modal" class="modal-backdrop hidden">
<div class="modal-window">
<div class="modal-header">
<h3>✏️ Добавить текст или статью</h3>
<button id="close-add-book-modal-btn" class="icon-btn">✕</button>
</div>
<form id="add-book-form">
<div class="modal-body">
<div class="form-group">
<label for="book-title-input">Название книги / статьи *</label>
<input type="text" id="book-title-input" class="input-styled" required placeholder="Например: Clean Code Principles">
</div>
<div class="form-group">
<label for="book-author-input">Автор / Источник</label>
<input type="text" id="book-author-input" class="input-styled" placeholder="Например: Robert C. Martin">
</div>
<div class="form-group">
<label for="book-content-input">Текст материала (English) *</label>
<textarea id="book-content-input" class="input-styled" rows="10" required placeholder="Вставьте английский текст книги, статьи или документации..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" id="cancel-add-book-btn" class="btn btn-outline">Отмена</button>
<button type="submit" id="submit-add-book-btn" class="btn btn-primary">📚 Сохранить в библиотеку</button>
</div>
</form>
</div>
</div>
+581 -20
View File
@@ -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
// -----------------------------------------------------------------------