513 lines
21 KiB
JavaScript
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();
|
|
});
|