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