/** * Main application shell — SPA router and layout. */ import { CalendarView } from './calendar-view'; import { BibleView } from './bible-view'; import { PrayerView } from './prayer-view'; import { MenaionView } from './menaion-view'; import { SettingsView } from './settings-view'; import { JDate } from '../core/jdate'; import { loadSettings, type Settings } from './settings-view'; import { getTranslations, type LanguageCode } from '../core/i18n'; import { DataCache } from '../core/data-cache'; type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'lives' | 'settings'; export class App { private container: HTMLElement; private currentView: View = 'calendar'; private currentDate: JDate; private settings: Settings; private routeParam: string = ''; constructor(container: HTMLElement) { this.container = container; this.currentDate = JDate.today(); this.settings = loadSettings(); document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px'); this.applyTheme(); } private applyTheme() { document.documentElement.className = 'theme-' + (this.settings.theme || 'default'); const chromeColor = getComputedStyle(document.documentElement).getPropertyValue('--clr-browser-chrome').trim(); if (chromeColor) { document.querySelector('meta[name="theme-color"]')?.setAttribute('content', chromeColor); } } private async checkDataVersion() { const DATA_VERSION = '2026-08-04'; // bump when data structure changes try { const cachedVer = await DataCache.get('__data_version__') as string | null; if (cachedVer !== DATA_VERSION) { await DataCache.clear(); await DataCache.set('__data_version__', DATA_VERSION); } } catch { // Silently fail — cache is best-effort } } init() { this.container.innerHTML = ''; this.render(); this.handleRoute(); window.addEventListener('hashchange', () => this.handleRoute()); // Data version check — bump DATA_VERSION when static data structure changes this.checkDataVersion(); // Close dropdown menus on click/touch outside const closeOpenDropdowns = (e: Event) => { const target = e.target as Node; document.querySelectorAll('details.nav-group[open]').forEach(d => { if (!d.contains(target)) { d.removeAttribute('open'); } }); }; document.addEventListener('click', closeOpenDropdowns); document.addEventListener('touchstart', closeOpenDropdowns, { passive: true }); // Close library dropdown when a nav link inside it is clicked document.addEventListener('click', (e) => { const target = e.target as HTMLElement; const link = target.closest('.nav-link'); if (link) { document.querySelectorAll('details.nav-group[open]').forEach(d => { d.removeAttribute('open'); }); } }); // PWA install prompt window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); (window as any).__deferredPrompt = e; }); // Clear first-launch flag on re-install window.addEventListener('appinstalled', () => { localStorage.removeItem('ponomar-installed'); }); // Show offline offer on first standalone launch const isStandalone = () => window.matchMedia('(display-mode: standalone)').matches || (window.navigator as any).standalone; if (isStandalone() && !localStorage.getItem('ponomar-installed')) { localStorage.setItem('ponomar-installed', '1'); this.showOfflineOfferModal(); } // Auto-preload essential data for the current language this.autoPreload(); } private async autoPreload(): Promise { try { const key = 'ponomar-preloaded'; if (localStorage.getItem(key)) return; const lang = this.settings.language as LanguageCode; const languages = lang === 'ru' ? ['ru', 'cu'] : [lang]; for (const l of languages) { await DataCache.fetch(`/data/${l}/menaion-bundle.json`).catch(() => {}); const mm = String(JDate.today().getMonth()).padStart(2, '0'); await DataCache.fetch(`/data/${l}/lives/${mm}.json`).catch(() => {}); } await DataCache.fetch('/data/shared/fasting.json').catch(() => {}); localStorage.setItem(key, '1'); } catch { // Silently fail — user can manually preload via settings } } private showOfflineOfferModal() { const t = getTranslations(this.settings.language as LanguageCode); const overlay = document.createElement('div'); overlay.id = 'offline-offer-overlay'; overlay.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50'; overlay.innerHTML = `

${t.settings.offlineOfferTitle}

${t.settings.offlineOfferText}

`; document.body.appendChild(overlay); document.getElementById('offline-offer-all')?.addEventListener('click', () => { overlay.remove(); window.location.hash = '#settings/autopreload'; }); document.getElementById('offline-offer-choose')?.addEventListener('click', () => { overlay.remove(); window.location.hash = '#settings'; }); document.getElementById('offline-offer-skip')?.addEventListener('click', () => { overlay.remove(); }); } private handleRoute() { const hash = window.location.hash.slice(1) || 'calendar'; const parts = hash.split('/'); this.currentView = parts[0] as View || 'calendar'; this.routeParam = parts[1] || ''; this.renderView(); } private resolveBibleVersion(shortId: string, lang: string): string { // Map short version IDs like "kjv" to full paths like "en/bible/kjv" const langMap: Record = { en: 'en/bible/kjv', ru: 'ru/bible/synod', cu: 'cu/bible/elis', }; if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return `en/bible/${shortId}`; if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return `ru/bible/${shortId}`; if (shortId === 'elis') return 'cu/bible/elis'; if (shortId === 'ls') return 'fr/bible/ls'; if (shortId === 'spt') return 'el/bible/spt'; if (shortId === 'vulgate') return 'la/bible/vulgate'; if (shortId === 'cuv') return 'zh/Hans/bible/cuv'; if (shortId === 'cuv-hant') return 'zh/Hant/bible/cuv'; if (shortId === 'svd') return 'ar/bible/svd'; return langMap[lang] || 'en/bible/kjv'; } private render() { this.settings = loadSettings(); this.applyTheme(); const t = getTranslations(this.settings.language as LanguageCode); this.container.innerHTML = ` `; } private renderView() { const viewContainer = document.getElementById('view-container')!; viewContainer.innerHTML = ''; // Update active nav document.querySelectorAll('.nav-link').forEach(el => { const v = el.getAttribute('data-view'); const isActive = v === this.currentView; el.classList.toggle('text-gold', isActive); el.classList.toggle('font-bold', isActive); }); // Re-read settings fresh on each navigation this.settings = loadSettings(); const lang = this.settings.language as LanguageCode; const calendarType = this.settings.calendarType || 'gregorian'; switch (this.currentView) { case 'calendar': new CalendarView(viewContainer, this.currentDate, (date) => { this.currentDate = date; }, lang, calendarType).render(); break; case 'bible': { // Parse route params: #bible/{lang~short}/{book}/{passage} const hash = window.location.hash.slice(1); const bp = hash.split('/'); const versionSpec = bp[1] || ''; const bibleBook = bp[2] || ''; const biblePassage = bp[3] || ''; let bibleVersion: string; if (!versionSpec) { const lastVersion = localStorage.getItem('ponomar-last-bible-version'); bibleVersion = lastVersion || this.resolveBibleVersion(this.settings.defaultBibleVersion, lang); } else if (versionSpec.includes('~')) { // Format: lang~short e.g. "en~kjv" → "en/bible/kjv" const [vl, vs] = versionSpec.split('~'); bibleVersion = `${vl}/bible/${vs}`; } else if (versionSpec.includes('/')) { bibleVersion = versionSpec; // legacy full path } else { bibleVersion = this.resolveBibleVersion(versionSpec, lang); } new BibleView(viewContainer, lang, bibleVersion, bibleBook, biblePassage).render(); break; } case 'prayer': new PrayerView(viewContainer, lang, 'prayer-rule').render(); break; case 'akathists': new PrayerView(viewContainer, lang, 'akathists').render(); break; case 'parimii': new PrayerView(viewContainer, lang, 'parimii').render(); break; case 'horologion': new PrayerView(viewContainer, lang, 'horologion').render(); break; case 'sbornik': new PrayerView(viewContainer, lang, 'sbornik').render(); break; case 'paraclete': new PrayerView(viewContainer, lang, 'paraclete').render(); break; case 'irmologion': new PrayerView(viewContainer, lang, 'irmologion').render(); break; case 'menaion': new MenaionView(viewContainer, lang).render(); break; case 'triodion': new PrayerView(viewContainer, lang, 'triodion').render(); break; case 'settings': new SettingsView(viewContainer, () => { this.render(); this.renderView(); }, this.routeParam).render(); break; } } go(view: View) { window.location.hash = view; } }