/** * Settings view — language, font, and display preferences. */ import { getTranslations, type LanguageCode } from '../core/i18n'; import { OfflineManager, type PreloadProgress } from '../core/offline-manager'; import { DataCache } from '../core/data-cache'; import { diagTail } from '../core/diag-log'; import { isPwaInstalled } from '../core/pwa-install'; /** Human message for a finished preload run (shared by manual + auto preload). */ function preloadResultText( prog: PreloadProgress, s: { offlineAborted: string; offlineDone: string; offlineFailed: string; offlineDegraded: string; offlineMissing: string }, ): string { if (prog.aborted) return s.offlineAborted; if (prog.manifestMissing) return s.offlineDegraded; if (prog.failed > 0) { const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : ''; return `${s.offlineDone} ${prog.failed} ${s.offlineFailed}${failList}`; } if ((prog.missing ?? 0) > 0) return s.offlineMissing.replace('{0}', String(prog.missing)); return s.offlineDone; } const LANGUAGES = [ { code: 'en', name: 'English', local: 'English' }, { code: 'ru', name: 'Russian', local: 'Русский' }, { code: 'cu', name: 'Church Slavonic', local: 'Церковнославянский' }, ]; const CU_FONTS = [ { id: '', name: 'System Default' }, { id: 'Ponomar', name: 'Ponomar' }, { id: 'Fedorovsk', name: 'Fedorovsk' }, { id: 'Menaion', name: 'Menaion' }, { id: 'Monomakh', name: 'Monomakh' }, { id: 'Triodion', name: 'Triodion' }, { id: 'Vilnius', name: 'Vilnius' }, { id: 'Voskresensky', name: 'Voskresensky' }, ]; const SETTINGS_KEY = 'ponomar-settings'; interface Settings { language: string; cuFont: string; fontSize: number; theme: string; defaultBibleVersion: string; showVerseNumbers: boolean; verseNewLine: boolean; calendarType: 'julian' | 'gregorian'; serviceRole: 'priest' | 'reader' | 'auto'; latitude: number; longitude: number; } const DEFAULT_SETTINGS: Settings = { language: 'en', cuFont: 'Ponomar', fontSize: 18, theme: 'default', defaultBibleVersion: 'kjv', showVerseNumbers: true, verseNewLine: false, calendarType: 'gregorian', serviceRole: 'priest', latitude: 55.7558, longitude: 37.6176, }; function defaultBibleForLanguage(lang: string): string { const map: Record = { en: 'kjv', ru: 'synod', cu: 'elis' }; return map[lang] || 'kjv'; } function versionPriority(lang: string, userLang: string): number { if (lang === userLang) return 0; if ((userLang === 'ru' || userLang === 'cu') && (lang === 'ru' || lang === 'cu')) return 1; return 2; } function detectBrowserLanguage(): LanguageCode { const lang = navigator.language || (navigator as any).userLanguage || ''; if (lang.startsWith('ru')) return 'ru'; if (lang.startsWith('cu')) return 'cu'; return 'en'; } function loadSettings(): Settings { try { const stored = localStorage.getItem(SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored); // Migrate old usePonomarFont to cuFont if (parsed.usePonomarFont !== undefined) { parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : ''; delete parsed.usePonomarFont; } return { ...DEFAULT_SETTINGS, ...parsed }; } } catch { /* ignore */ } return { ...DEFAULT_SETTINGS, language: detectBrowserLanguage(), defaultBibleVersion: defaultBibleForLanguage(detectBrowserLanguage()) }; } function saveSettings(settings: Settings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } function fontClass(fontName: string): string { if (!fontName) return 'font-system-slavonic'; const lower = fontName.toLowerCase(); const map: Record = { ponomar: 'font-ponomar', fedorovsk: 'font-fedrovsk', indiction: 'font-indiction', menaion: 'font-menaion', monomakh: 'font-monomakh', triodion: 'font-triodion', vilnius: 'font-vilnius', voskresensky: 'font-voskresensky', }; return map[lower] || 'font-slavonic'; } export { loadSettings, saveSettings, fontClass }; export type { Settings }; export class SettingsView { private container: HTMLElement; private settings: Settings; private onLanguageChange?: () => void; private action?: string; constructor(container: HTMLElement, onLanguageChange?: () => void, action?: string) { this.container = container; this.settings = loadSettings(); this.onLanguageChange = onLanguageChange; this.action = action; } render() { const t = getTranslations(this.settings.language as LanguageCode); this.container.innerHTML = `

${t.settings.title}

${t.settings.theme}

${t.settings.fontSettings}

${t.settings.fontPreview}

Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ

${t.settings.calendarType}

${t.settings.locationTitle}

${t.settings.locationNote}

${t.settings.bibleSettings}

${t.settings.offlineContent}

${LANGUAGES.map(l => ` `).join('')}
${t.settings.offlineBible} (0/0) ▶
${t.settings.offlineCalculating}

        

${t.settings.about}

${t.settings.aboutAppName}${t.settings.aboutText}

${t.settings.aboutLicense}

build ${__BUILD_DATE__}

`; // Load default Bible versions dynamically (async () => { try { const versions = await DataCache.fetchWithFallback('/data/bible/versions.json'); if (!versions || !Array.isArray(versions)) return; const userLang = this.settings.language; // Sort by language priority versions.sort((a: any, b: any) => { const pa = versionPriority(a.language, userLang); const pb = versionPriority(b.language, userLang); return pa - pb || a.name.localeCompare(b.name); }); // Build options in priority order const select = document.getElementById('default-bible') as HTMLSelectElement; if (select) { select.innerHTML = versions.map((v: any) => { const shortId = v.id.split('/').pop(); const size = OfflineManager.getBibleSize(v.id); const comment = size ? ` (${size})` : ''; return ``; }).join(''); } } catch {} })(); // Event listeners document.getElementById('language')?.addEventListener('change', (e) => { this.settings.language = (e.target as HTMLSelectElement).value; this.settings.defaultBibleVersion = defaultBibleForLanguage(this.settings.language); saveSettings(this.settings); const bibleSelect = document.getElementById('default-bible') as HTMLSelectElement | null; if (bibleSelect) bibleSelect.value = this.settings.defaultBibleVersion; this.onLanguageChange?.(); }); document.getElementById('cu-font')?.addEventListener('change', (e) => { this.settings.cuFont = (e.target as HTMLSelectElement).value; saveSettings(this.settings); this.updateFontPreview(); }); document.getElementById('font-size')?.addEventListener('input', (e) => { this.settings.fontSize = parseInt((e.target as HTMLInputElement).value, 10); document.getElementById('font-size-value')!.textContent = `${this.settings.fontSize}px`; document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px'); this.updateFontPreview(); saveSettings(this.settings); }); document.getElementById('theme')?.addEventListener('change', (e) => { this.settings.theme = (e.target as HTMLSelectElement).value; saveSettings(this.settings); document.documentElement.className = 'theme-' + this.settings.theme; const chromeColor = getComputedStyle(document.documentElement).getPropertyValue('--clr-browser-chrome').trim(); if (chromeColor) { document.querySelector('meta[name="theme-color"]')?.setAttribute('content', chromeColor); } }); document.getElementById('default-bible')?.addEventListener('change', (e) => { this.settings.defaultBibleVersion = (e.target as HTMLSelectElement).value; saveSettings(this.settings); }); document.getElementById('show-verses')?.addEventListener('change', (e) => { this.settings.showVerseNumbers = (e.target as HTMLInputElement).checked; saveSettings(this.settings); }); document.getElementById('verse-newline')?.addEventListener('change', (e) => { this.settings.verseNewLine = (e.target as HTMLInputElement).checked; saveSettings(this.settings); }); document.querySelectorAll('input[name="calendar-type"]').forEach(el => { el.addEventListener('change', (e) => { this.settings.calendarType = (e.target as HTMLInputElement).value as 'julian' | 'gregorian'; saveSettings(this.settings); }); }); document.getElementById('install-pwa')?.addEventListener('click', async () => { const prompt = (window as any).__deferredPrompt; const hintEl = document.getElementById('install-hint'); if (prompt) { prompt.prompt(); await prompt.userChoice; (window as any).__deferredPrompt = null; document.getElementById('install-pwa')?.remove(); return; } if (!hintEl) return; const ua = navigator.userAgent; let hint: string; if (await isPwaInstalled()) { hint = t.settings.installPwaAlreadyInstalled; } else if (/firefox/i.test(ua)) { hint = t.settings.installPwaHintFirefox; } else if (/iphone|ipad|ipod/i.test(ua)) { hint = t.settings.installPwaHintIos; } else { hint = t.settings.installPwaHintGeneric; } hintEl.textContent = hint; hintEl.classList.remove('hidden'); }); // Show install button only when actually installable (async () => { const btn = document.getElementById('install-pwa'); if (!btn || !('serviceWorker' in navigator)) return; try { if (!(await isPwaInstalled())) btn.classList.remove('hidden'); } catch { /* stay hidden */ } })(); // Location const saveLatLng = () => { const lat = parseFloat((document.getElementById('latitude') as HTMLInputElement)?.value); const lng = parseFloat((document.getElementById('longitude') as HTMLInputElement)?.value); if (!isNaN(lat) && !isNaN(lng)) { this.settings.latitude = lat; this.settings.longitude = lng; saveSettings(this.settings); } }; document.getElementById('latitude')?.addEventListener('change', saveLatLng); document.getElementById('longitude')?.addEventListener('change', saveLatLng); document.getElementById('request-location')?.addEventListener('click', () => { if ('geolocation' in navigator) { navigator.geolocation.getCurrentPosition( (pos) => { this.settings.latitude = Math.round(pos.coords.latitude * 10000) / 10000; this.settings.longitude = Math.round(pos.coords.longitude * 10000) / 10000; saveSettings(this.settings); (document.getElementById('latitude') as HTMLInputElement).value = String(this.settings.latitude); (document.getElementById('longitude') as HTMLInputElement).value = String(this.settings.longitude); }, () => alert('Could not get location. Make sure location access is allowed.') ); } else { alert('Geolocation is not available in this browser.'); } }); // Bible translations expand/collapse via Bible row document.getElementById('bible-row')?.addEventListener('click', async () => { const container = document.getElementById('bible-translations'); const chevron = document.getElementById('bible-chevron'); if (!container || !chevron) return; const isHidden = container.classList.contains('hidden'); if (isHidden) { // Load translations on first expand if (container.innerHTML === '') { try { const versions = await DataCache.fetchWithFallback('/data/bible/versions.json'); if (!versions || !Array.isArray(versions)) throw new Error('unavailable'); const userLang = this.settings.language; versions.sort((a: any, b: any) => { const pa = versionPriority(a.language, userLang); const pb = versionPriority(b.language, userLang); return pa - pb || a.name.localeCompare(b.name); }); const html = await Promise.all(versions.map(async (v: any) => { const firstBook = v.books?.[0]?.id; let isCached = false; if (firstBook) { isCached = (await DataCache.get(`url:/data/${v.id}/${firstBook}.text`)) !== null; } return ` `; })); container.innerHTML = `
` + html.join(''); document.getElementById('select-all-bible')?.addEventListener('click', () => { document.querySelectorAll('.bible-trans').forEach(el => { (el as HTMLInputElement).checked = true; }); }); } catch {} } container.classList.remove('hidden'); chevron.textContent = '▼'; } else { container.classList.add('hidden'); chevron.textContent = '▶'; } }); // Offline content - auto-select ru+cu when ru is selected document.querySelectorAll('.offline-lang').forEach(el => { el.addEventListener('change', () => { const ruChecked = (document.querySelector('.offline-lang[value="ru"]') as HTMLInputElement)?.checked; const cuCheckbox = document.querySelector('.offline-lang[value="cu"]') as HTMLInputElement; if (ruChecked && cuCheckbox) { cuCheckbox.checked = true; } }); }); // Preload button document.getElementById('offline-preload')?.addEventListener('click', async () => { const selectedLangs = Array.from(document.querySelectorAll('.offline-lang:checked')) .map(el => (el as HTMLInputElement).value); const selectedTypes = Array.from(document.querySelectorAll('.offline-type:checked')) .map(el => (el as HTMLInputElement).value) as ('calendar' | 'bible')[]; if (selectedLangs.length === 0) { const progressDiv = document.getElementById('offline-progress'); if (progressDiv) progressDiv.classList.remove('hidden'); const progressText = document.getElementById('offline-progress-text'); if (progressText) progressText.textContent = t.settings.offlineSelectLang; return; } const progressDiv = document.getElementById('offline-progress'); const progressBar = document.getElementById('offline-progress-bar'); const progressText = document.getElementById('offline-progress-text'); if (progressDiv) progressDiv.classList.remove('hidden'); const bibleTrans = Array.from(document.querySelectorAll('.bible-trans:checked')) .map(el => (el as HTMLInputElement).value); // Start preloading in background OfflineManager.preload({ languages: selectedLangs, types: selectedTypes, bibleTranslations: bibleTrans, }).then((prog) => { if (progressBar) progressBar.style.width = '100%'; if (progressText) progressText.textContent = preloadResultText(prog, t.settings); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); OfflineManager.getStats().then(stats => { const statsEl = document.getElementById('offline-stats'); if (statsEl) { statsEl.textContent = t.settings.offlineCacheInfo .replace('{0}', OfflineManager.formatBytes(stats.usageBytes)) .replace('{2}', String(stats.cachedFiles)); } }); }); // Poll progress const pollInterval = setInterval(() => { const prog = OfflineManager.getProgress(); if (prog && prog.total > 0) { const pct = Math.round((prog.current / prog.total) * 100); if (progressBar) progressBar.style.width = `${pct}%`; if (progressText) progressText.textContent = `${prog.current} / ${prog.total} ${t.settings.offlineDataTypes} — ${prog.file.substring(0, 60)}...`; } if (!prog || prog.done) clearInterval(pollInterval); }, 200); }); // Clear cache button document.getElementById('offline-clear')?.addEventListener('click', async () => { await OfflineManager.clearCache(); const statsEl = document.getElementById('offline-stats'); if (statsEl) statsEl.textContent = t.settings.offlineCleared; const progressDiv = document.getElementById('offline-progress'); if (progressDiv) progressDiv.classList.add('hidden'); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); }); // Show initial storage stats OfflineManager.getStats().then(stats => { const statsEl = document.getElementById('offline-stats'); if (statsEl) { statsEl.textContent = t.settings.offlineCacheInfo .replace('{0}', OfflineManager.formatBytes(stats.usageBytes)) .replace('{1}', OfflineManager.formatBytes(stats.quotaBytes)) .replace('{2}', String(stats.cachedFiles)); } }); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); // Check cache status for each data type and set checkbox states (async () => { const checkCache = async (key: string): Promise => { return (await DataCache.get(key)) !== null; }; const setChecked = (selector: string, checked: boolean) => { const el = document.querySelector(selector) as HTMLInputElement | null; if (el) el.checked = checked; }; // Check main types setChecked('.offline-type[value="calendar"]', await checkCache('url:/data/shared/fasting.json')); // Count cached Bible translations (async () => { try { const versions = await DataCache.fetchWithFallback('/data/bible/versions.json'); if (!versions || !Array.isArray(versions)) return; const total = versions.length; let cached = 0; for (const v of versions) { const firstBook = v.books?.[0]?.id; if (firstBook && await checkCache(`url:/data/${v.id}/${firstBook}.text`)) { cached++; } } const countEl = document.getElementById('bible-count'); if (countEl) countEl.textContent = `(${cached}/${total})`; } catch {} })(); // Check language checkboxes based on cache status for (const lang of ['en', 'ru', 'cu']) { const isCached = await checkCache(`url:/data/${lang}/lives/01.json`); setChecked(`.offline-lang[value="${lang}"]`, isCached); } // If nothing cached, default to current interface language const anyChecked = document.querySelector('.offline-lang:checked'); if (!anyChecked) { setChecked(`.offline-lang[value="${this.settings.language}"]`, true); } // Auto-select cu when ru is checked const ruChecked = (document.querySelector('.offline-lang[value="ru"]') as HTMLInputElement)?.checked; const cuCheckbox = document.querySelector('.offline-lang[value="cu"]') as HTMLInputElement; if (ruChecked && cuCheckbox) { cuCheckbox.checked = true; } // Auto-preload everything if triggered from first-launch offer if (OfflineManager.consumeAutoPreload() || this.action === 'autopreload') { document.querySelectorAll('.offline-lang').forEach(el => (el as HTMLInputElement).checked = true); document.querySelectorAll('.offline-type').forEach(el => (el as HTMLInputElement).checked = true); const progressDiv = document.getElementById('offline-progress'); const progressBar = document.getElementById('offline-progress-bar'); const progressText = document.getElementById('offline-progress-text'); if (progressDiv) progressDiv.classList.remove('hidden'); // Reveal + select all Bible variants for UI consistency document.getElementById('bible-row')?.dispatchEvent(new Event('click')); setTimeout(() => { document.querySelectorAll('.bible-trans').forEach(el => (el as HTMLInputElement).checked = true); }, 300); const selectedLangs = LANGUAGES.map(l => l.code); const selectedTypes: ('calendar' | 'bible')[] = ['calendar', 'bible']; OfflineManager.preload({ languages: selectedLangs, types: selectedTypes, bibleTranslations: 'all', }).then((prog) => { if (progressBar) progressBar.style.width = '100%'; if (progressText) progressText.textContent = preloadResultText(prog, t.settings); const diagEl = document.getElementById('diag-log'); if (diagEl) diagEl.textContent = diagTail(); OfflineManager.getStats().then(stats => { const statsEl = document.getElementById('offline-stats'); if (statsEl) { statsEl.textContent = t.settings.offlineCacheInfo .replace('{0}', OfflineManager.formatBytes(stats.usageBytes)) .replace('{2}', String(stats.cachedFiles)); } }); }); const pollInterval = setInterval(() => { const prog = OfflineManager.getProgress(); if (prog && prog.total > 0) { const pct = Math.round((prog.current / prog.total) * 100); if (progressBar) progressBar.style.width = `${pct}%`; if (progressText) progressText.textContent = `${prog.current} / ${prog.total} ${t.settings.offlineDataTypes} — ${prog.file.substring(0, 60)}...`; } if (!prog || prog.done) clearInterval(pollInterval); }, 200); window.history.replaceState(null, '', '#settings'); } })(); this.updateFontPreview(); } private updateFontPreview() { const preview = document.getElementById('font-preview'); if (preview) { preview.className = `${fontClass(this.settings.cuFont)} text-lg`; preview.style.fontSize = `${this.settings.fontSize}px`; } } }