Files
notes-qpid-pro/app/static/js/app.js
T

513 lines
21 KiB
JavaScript

// =======================================================================
// Notes Application - Main Orchestrator & Router (Native ES Module)
// Zero Bundlers, Zero npm, Native browser ES6 modules
// =======================================================================
import { api } from './modules/api.js';
import { initTheme, openModal, closeModal, showToast } from './modules/ui.js';
import { initDictionary, loadInitialData } from './modules/dictionary.js';
import { initSrs, startSrsTrainingSession, openStatsDashboard } from './modules/srs.js';
import { initReader, loadBookshelf, openBook } from './modules/reader.js';
let currentUser = null;
let isRouting = false;
// -----------------------------------------------------------------------
// Mobile Navigation Active Tab
// -----------------------------------------------------------------------
export 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');
}
// -----------------------------------------------------------------------
// Immediate Pre-flight Routing (Zero-flicker view restoration on F5)
// -----------------------------------------------------------------------
export function applyInitialViewPreflight() {
let hash = window.location.hash || '';
if (!hash || hash === '#') {
try {
const lastView = localStorage.getItem('notes_active_view');
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastView === 'reader-view') {
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
} else if (lastView === 'srs-view') {
hash = '#srs';
}
} catch (e) {}
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
const readerBookTitle = document.getElementById('reader-book-title');
if (hash.startsWith('#reader') || hash === '#bookshelf') {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
updateMobileActiveTab('reader');
if (hash.includes('/book/')) {
if (bookshelfContainer) bookshelfContainer.classList.add('hidden');
if (readerInterface) readerInterface.classList.remove('hidden');
if (readerBookTitle) readerBookTitle.textContent = 'Загрузка книги...';
}
} else if (hash.startsWith('#srs')) {
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
}
}
// -----------------------------------------------------------------------
// View Transitions
// -----------------------------------------------------------------------
export function openDictionaryView(updateHash = true) {
if (updateHash && window.location.hash !== '#dictionary') {
window.location.hash = '#dictionary';
return;
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (dictionaryView) dictionaryView.classList.remove('hidden');
updateMobileActiveTab('dict');
try { localStorage.setItem('notes_active_view', 'dictionary-view'); } catch (e) {}
loadInitialData();
}
export function openSrsView(tagFilter = null, updateHash = true) {
const targetHash = tagFilter ? `#srs/${encodeURIComponent(tagFilter)}` : '#srs';
if (updateHash && window.location.hash !== targetHash) {
window.location.hash = targetHash;
return;
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (readerView) readerView.classList.add('hidden');
if (srsView) srsView.classList.remove('hidden');
updateMobileActiveTab('srs');
try { localStorage.setItem('notes_active_view', 'srs-view'); } catch (e) {}
startSrsTrainingSession(false, tagFilter);
}
export function closeSrsView() {
openDictionaryView(true);
}
export function openBookshelfView(updateHash = true) {
if (updateHash && window.location.hash !== '#reader') {
window.location.hash = '#reader';
return;
}
const dictionaryView = document.getElementById('dictionary-view');
const srsView = document.getElementById('srs-view');
const readerView = document.getElementById('reader-view');
const bookshelfContainer = document.getElementById('bookshelf-container');
const readerInterface = document.getElementById('reader-interface');
if (dictionaryView) dictionaryView.classList.add('hidden');
if (srsView) srsView.classList.add('hidden');
if (readerView) readerView.classList.remove('hidden');
if (bookshelfContainer) bookshelfContainer.classList.remove('hidden');
if (readerInterface) readerInterface.classList.add('hidden');
updateMobileActiveTab('reader');
try {
localStorage.setItem('notes_active_view', 'reader-view');
localStorage.removeItem('notes_active_book_id');
} catch (e) {}
loadBookshelf();
}
export function openReaderView(updateHash = true) {
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastBookId) {
window.location.hash = `#reader/book/${lastBookId}`;
} else {
openBookshelfView(updateHash);
}
}
// -----------------------------------------------------------------------
// Route Handler
// -----------------------------------------------------------------------
export async function handleRouting() {
if (isRouting) return;
isRouting = true;
try {
let hash = window.location.hash || '';
if (!hash || hash === '#') {
try {
const lastView = localStorage.getItem('notes_active_view');
const lastBookId = localStorage.getItem('notes_active_book_id');
if (lastView === 'reader-view') {
hash = lastBookId ? `#reader/book/${lastBookId}` : '#reader';
window.location.hash = hash;
return;
} else if (lastView === 'srs-view') {
hash = '#srs';
window.location.hash = hash;
return;
} else {
hash = '#dictionary';
}
} catch (e) {
hash = '#dictionary';
}
}
const cleanHash = hash.replace(/^#\/?/, '');
if (!cleanHash || cleanHash === 'dictionary') {
openDictionaryView(false);
} else if (cleanHash.startsWith('srs')) {
const parts = cleanHash.split('/');
const tag = parts.length > 1 ? decodeURIComponent(parts.slice(1).join('/')) : null;
openSrsView(tag, false);
} else if (cleanHash.startsWith('reader') || cleanHash === 'bookshelf') {
const parts = cleanHash.split('/');
if (parts[1] === 'book' && parts[2]) {
const bookId = parseInt(parts[2], 10);
const chIdx = (parts[3] === 'ch' && parts[4] !== undefined) ? parseInt(parts[4], 10) : null;
await openBook(bookId, chIdx, false);
} else {
openBookshelfView(false);
}
} else {
openDictionaryView(false);
}
} finally {
isRouting = false;
}
}
// -----------------------------------------------------------------------
// Session & Auth
// -----------------------------------------------------------------------
export async function checkAuth() {
try {
const resp = await api.auth.me();
if (resp.ok) {
const data = await resp.json();
currentUser = data.user;
showApp();
await handleRouting();
loadInitialData();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
export function showLogin() {
const loginOverlay = document.getElementById('login-overlay');
const appShell = document.getElementById('app-shell');
const loginUsername = document.getElementById('login-username');
if (loginOverlay) loginOverlay.classList.remove('hidden');
if (appShell) appShell.classList.add('hidden');
if (loginUsername) loginUsername.focus();
}
export function showApp() {
const loginOverlay = document.getElementById('login-overlay');
const appShell = document.getElementById('app-shell');
const userDisplayName = document.getElementById('user-display-name');
const mobileUserName = document.getElementById('mobile-user-name');
if (loginOverlay) loginOverlay.classList.add('hidden');
if (appShell) appShell.classList.remove('hidden');
const name = currentUser ? currentUser.username : 'user';
if (userDisplayName) userDisplayName.textContent = name;
if (mobileUserName) mobileUserName.textContent = name;
}
// -----------------------------------------------------------------------
// Mobile Navigation & Drawer
// -----------------------------------------------------------------------
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');
const exportModal = document.getElementById('export-modal');
const changePasswordModal = document.getElementById('change-password-modal');
const themeToggle = document.getElementById('theme-toggle');
const logoutBtn = document.getElementById('logout-btn');
if (tabDict) tabDict.addEventListener('click', () => { window.location.hash = '#dictionary'; });
if (tabSrs) tabSrs.addEventListener('click', () => { window.location.hash = '#srs'; });
if (tabReader) tabReader.addEventListener('click', () => { window.location.hash = '#reader'; });
if (tabStats) {
tabStats.addEventListener('click', () => {
openStatsDashboard();
});
}
function openMobileMenu() {
if (mobileMenuModal) {
const mobileUserName = document.getElementById('mobile-user-name');
if (mobileUserName && currentUser) {
mobileUserName.textContent = currentUser.username;
}
openModal(mobileMenuModal);
}
}
function closeMobileMenu() {
if (mobileMenuModal) {
closeModal(mobileMenuModal);
}
}
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();
openStatsDashboard();
});
}
if (mobileExportBtn && exportModal) {
mobileExportBtn.addEventListener('click', () => {
closeMobileMenu();
openModal(exportModal);
});
}
if (mobilePwdBtn && changePasswordModal) {
mobilePwdBtn.addEventListener('click', () => {
closeMobileMenu();
openModal(changePasswordModal);
});
}
if (mobileThemeBtn && themeToggle) {
mobileThemeBtn.addEventListener('click', () => {
themeToggle.click();
});
}
if (mobileLogoutBtn && logoutBtn) {
mobileLogoutBtn.addEventListener('click', () => {
closeMobileMenu();
logoutBtn.click();
});
}
}
// -----------------------------------------------------------------------
// Password Change Modal
// -----------------------------------------------------------------------
function initChangePasswordModal() {
const changePasswordModal = document.getElementById('change-password-modal');
const closeChangePwdBtn = document.getElementById('close-change-pwd-btn');
const changePwdCancelBtn = document.getElementById('change-pwd-cancel-btn');
const changePwdOpenBtn = document.getElementById('change-pwd-open-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');
if (changePwdOpenBtn && changePasswordModal) {
changePwdOpenBtn.addEventListener('click', () => {
if (changePwdError) changePwdError.classList.add('hidden');
if (changePwdSuccess) changePwdSuccess.classList.add('hidden');
if (currentPasswordInput) currentPasswordInput.value = '';
if (newPasswordInput) newPasswordInput.value = '';
if (confirmPasswordInput) confirmPasswordInput.value = '';
openModal(changePasswordModal);
if (currentPasswordInput) currentPasswordInput.focus();
});
}
if (closeChangePwdBtn && changePasswordModal) {
closeChangePwdBtn.addEventListener('click', () => closeModal(changePasswordModal));
}
if (changePwdCancelBtn && changePasswordModal) {
changePwdCancelBtn.addEventListener('click', () => closeModal(changePasswordModal));
}
if (changePasswordForm) {
changePasswordForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (changePwdError) changePwdError.classList.add('hidden');
if (changePwdSuccess) changePwdSuccess.classList.add('hidden');
const currentPwd = currentPasswordInput ? currentPasswordInput.value : '';
const newPwd = newPasswordInput ? newPasswordInput.value : '';
const confirmPwd = confirmPasswordInput ? confirmPasswordInput.value : '';
if (newPwd !== confirmPwd) {
if (changePwdError) {
changePwdError.textContent = 'Новые пароли не совпадают';
changePwdError.classList.remove('hidden');
}
return;
}
try {
const resp = await api.auth.changePassword(currentPwd, newPwd);
const data = await resp.json();
if (resp.ok) {
if (changePwdSuccess) {
changePwdSuccess.textContent = 'Пароль успешно обновлен!';
changePwdSuccess.classList.remove('hidden');
}
setTimeout(() => {
if (changePasswordModal) closeModal(changePasswordModal);
}, 1200);
} else {
if (changePwdError) {
changePwdError.textContent = data.detail || 'Ошибка смены пароля';
changePwdError.classList.remove('hidden');
}
}
} catch (err) {
if (changePwdError) {
changePwdError.textContent = 'Ошибка сетевого соединения';
changePwdError.classList.remove('hidden');
}
}
});
}
}
// -----------------------------------------------------------------------
// Initialization & Entry Point
// -----------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
// 1. PWA Service Worker Registration
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));
}
// 2. Pre-flight routing check to avoid DOM flicker on reload
applyInitialViewPreflight();
// 3. UI theme initialization
initTheme();
// 4. Initialize Feature Modules
initDictionary();
initSrs();
initReader();
// 5. Global Navigation & Header Actions
const navBrandHome = document.getElementById('nav-brand-home');
const navReaderBtn = document.getElementById('nav-reader-btn');
const srsTrainerBtn = document.getElementById('srs-trainer-btn');
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');
if (navBrandHome) {
navBrandHome.addEventListener('click', () => { window.location.hash = '#dictionary'; });
}
if (navReaderBtn) {
navReaderBtn.addEventListener('click', () => { window.location.hash = '#reader'; });
}
if (srsTrainerBtn) {
srsTrainerBtn.addEventListener('click', () => { window.location.hash = '#srs'; });
}
// 6. Mobile Tab Bar & Drawer
initMobileNavigation();
// 7. Change Password Modal
initChangePasswordModal();
// 8. Hash Change Listener
window.addEventListener('hashchange', handleRouting);
// 9. Auth Actions
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (loginError) loginError.classList.add('hidden');
try {
const resp = await api.auth.login(
loginUsername ? loginUsername.value.trim() : '',
loginPassword ? loginPassword.value : ''
);
const data = await resp.json();
if (resp.ok && data.status === 'ok') {
if (data.token) {
localStorage.setItem('notes_token', data.token);
}
currentUser = data.user;
showApp();
await handleRouting();
loadInitialData();
} else {
if (loginError) {
loginError.textContent = data.detail || 'Неверный логин или пароль';
loginError.classList.remove('hidden');
}
}
} catch (err) {
if (loginError) {
loginError.textContent = 'Ошибка сетевого соединения';
loginError.classList.remove('hidden');
}
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
localStorage.removeItem('notes_token');
localStorage.removeItem('notes_active_book_id');
try { await api.auth.logout(); } catch (e) {}
currentUser = null;
window.location.hash = '';
window.location.reload();
});
}
// 10. Check Session Auth & Kickoff
checkAuth();
});