Compare commits
10
Commits
a005e386a6
...
f534fe6006
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f534fe6006 | ||
|
|
69f3533c41 | ||
|
|
4f9f55b3f3 | ||
|
|
d0affe4e83 | ||
|
|
8c442bf7b6 | ||
|
|
1201e16d5e | ||
|
|
4f11e8ca6c | ||
|
|
4f6461efad | ||
|
|
9cef9130cc | ||
|
|
f49735b360 |
No files matched your search
@@ -16,6 +16,19 @@
|
||||
- **Bundle system**: menaion-bundles, lives bundles (month-prefixed + misc split), service templates
|
||||
- **File routing**: CIds with `01`-`12` prefix → month files, else `misc/{first-digit}.json`
|
||||
|
||||
### Language Model (UI vs. Content)
|
||||
- **UI strings**: en/ru/cu via `getTranslations()`; completeness checked by `validate-i18n.ts`
|
||||
- **Service language** (`serviceLang` in `service-view.ts`): `en → en`, `cu → cu`, **`ru → cu`** —
|
||||
a Russian interface intentionally renders services in Church Slavonic
|
||||
- **Service content fallback chain**: `serviceLang → cu → shared` (texts, prayers, headers,
|
||||
lives, octoechos, templates); `ru/services/` carries only `var/`
|
||||
- **Scripture inside services** follows the service language: en → `en/bible/kjv`,
|
||||
cu (and ru) → `cu/bible/elis`
|
||||
- **Bible reader tab** is independent: 13 translations (incl. `ru/bible/synod`), any UI language
|
||||
- **Fonts**: the Slavonic font setting applies to liturgical text for cu **and** ru
|
||||
(`getFontClass()`)
|
||||
- **Calendar authority**: `cu/menaion-bundle.json`; `{lang}-menaion-bundle.json` overrides
|
||||
|
||||
### Core Scripts
|
||||
- `convert-services.ts` — Service XML to JSON conversion
|
||||
- `enrich-lives.ts` — Fuzzy matching and enrichment pipeline (Chetyi-Minei + Bulgakov)
|
||||
@@ -44,6 +57,9 @@
|
||||
- **Bundle CIds must exist**: Menaion CIds require corresponding lives entries
|
||||
- **Static data is tracked**: `static/data/` in git (except `scripts/output/minei.json`)
|
||||
- **Validation must pass**: Changes must pass `tsc --noEmit`, `npm run build`
|
||||
- **Content policies**: service/Bible texts are sourced (never translated), cu text is
|
||||
verbatim (no normalisation/latinisation), service assembly follows the Typicon — see
|
||||
`.ai-specs/WORKING_POLICIES.md` ("Liturgical Content Policies")
|
||||
|
||||
## Key Script Quick Reference
|
||||
|
||||
|
||||
@@ -16,6 +16,27 @@
|
||||
- **Forbidden**: Deleting or renaming CIds without proper validation
|
||||
- **Forbidden**: Changing JSON structure without updating related code
|
||||
|
||||
## Liturgical Content Policies
|
||||
|
||||
### Never Translate Service or Bible Texts
|
||||
- All liturgical and scriptural texts must be **sourced** through the data pipeline
|
||||
(original-language conversions, Chetyi-Minei, Bulgakov, EPUB/PDF imports, etc.)
|
||||
- Never machine-translate, paraphrase or invent text — not even "just one line"
|
||||
- Missing text stays empty (the app falls back `serviceLang → cu → shared`) until its
|
||||
source is converted
|
||||
|
||||
### Church Slavonic Texts Are Presented Verbatim
|
||||
- Never "normalise", latinise, transliterate or strip diacritics/accents/titla from cu texts
|
||||
- Render the text exactly as stored in the source data; display-layer transformations
|
||||
are forbidden
|
||||
- Orthography fixes belong in the conversion pipeline and must preserve the source
|
||||
|
||||
### Service Assembly Follows the Typicon
|
||||
- Service-building logic (templates, ordering, conditional rules, canons) must reproduce
|
||||
the Typicon
|
||||
- Whenever service building logic is changed, check the expected structure against the
|
||||
Typicon (and the pinned service data) before and after the change
|
||||
|
||||
## Validation Requirements
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
+2
-2
@@ -51,8 +51,8 @@ static/data/
|
||||
│ ├── menaion/ # Daily menaion entries (366 days)
|
||||
│ ├── calendar/ # Calendar metadata
|
||||
│ └── services/ # Service templates
|
||||
├── cu/ # Church Slavonic (similar structure)
|
||||
├── ru/ # Russian (lives, services)
|
||||
├── cu/ # Church Slavonic (authoritative calendar + service texts)
|
||||
├── ru/ # Russian (enriched lives; services fall back to cu)
|
||||
├── el/ # Greek (includes mono/)
|
||||
├── fr/ # French
|
||||
├── zh/ # Chinese (includes Hans/, Hant/)
|
||||
|
||||
+2
-2
@@ -51,8 +51,8 @@ static/data/
|
||||
│ ├── menaion/ # Ежедневные записи минеи (366 дней)
|
||||
│ ├── calendar/ # Метаданные календаря
|
||||
│ └── services/ # Шаблоны богослужений
|
||||
├── cu/ # Церковнославянский (аналогичная структура)
|
||||
├── ru/ # Русский (жития, богослужения)
|
||||
├── cu/ # Церковнославянский (авторитетный календарь и богослужебные тексты)
|
||||
├── ru/ # Русский (обогащённые жития; богослужения берутся из cu)
|
||||
├── el/ # Греческий (включает mono/)
|
||||
├── fr/ # Французский
|
||||
├── zh/ # Китайский (включает Hans/, Hant/)
|
||||
|
||||
@@ -48,6 +48,21 @@ function calendarFiles(lang: string): string[] {
|
||||
if (exists(f)) files.push(`/data${f}`);
|
||||
}
|
||||
|
||||
// Shared fallback lives/commands — normally served per-language, but the
|
||||
// runtime chain ends with a 'shared' attempt; keep those copies offline too.
|
||||
for (const sub of ['lives', 'commands']) {
|
||||
const subDir = path.join(DATA_DIR, 'shared', sub);
|
||||
if (!fs.existsSync(subDir)) continue;
|
||||
const walkShared = (d: string, rel: string): void => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walkShared(p, `${rel}/${e.name}`);
|
||||
else if (e.name.endsWith('.json')) files.push(`/data/shared/${sub}${rel}/${e.name}`);
|
||||
}
|
||||
};
|
||||
walkShared(subDir, '');
|
||||
}
|
||||
|
||||
// Menaion bundle
|
||||
const mb = `/${lang}/menaion-bundle.json`;
|
||||
if (exists(mb)) files.push(`/data${mb}`);
|
||||
@@ -123,16 +138,6 @@ function calendarFiles(lang: string): string[] {
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
|
||||
// Menaion common + add
|
||||
for (let i = 1; i <= 42; i++) {
|
||||
const fp = `/shared/menaion-common/${String(i).padStart(2, '0')}.json`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
for (let i = 1; i <= 51; i++) {
|
||||
const fp = `/shared/menaion-add/${String(i).padStart(2, '0')}.json`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
|
||||
// Prayer collections
|
||||
const prayerDirs: [string, string[]][] = [];
|
||||
for (const coll of ['akathists', 'horologion', 'prayer-rule', 'sbornik', 'irmologion']) {
|
||||
@@ -149,19 +154,6 @@ function calendarFiles(lang: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Horologion additions
|
||||
const horAddItems = ['exapostilaria', 'katavasia', 'lamps', 'songs-daily', 'songs-feasts', 'songs-lent',
|
||||
'theotokia-8tones', 'theotokia-dismissal', 'theotokia-sunday', 'trinity-8tones',
|
||||
'trop-common', 'trop-daily', 'trop-lent', 'trop-pentecost', 'trop-sunday'];
|
||||
for (const item of horAddItems) {
|
||||
const fp = `/shared/horologionadd/${item}/full.json`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
for (const m of ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']) {
|
||||
const fp = `/shared/horologionadd/trop-feasts-${m}/full.json`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
|
||||
// Parimii
|
||||
const parimiiDir = path.join(DATA_DIR, 'shared', 'parimii');
|
||||
if (fs.existsSync(parimiiDir)) {
|
||||
@@ -191,14 +183,26 @@ function calendarFiles(lang: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Language-specific service commands
|
||||
for (let tone = 1; tone <= 8; tone++) {
|
||||
const fp = `/${lang}/services/commands/Tone${tone}.json`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
// Language-specific service commands (enumerate all on disk)
|
||||
const cmdDir = path.join(DATA_DIR, lang, 'services', 'commands');
|
||||
if (fs.existsSync(cmdDir)) {
|
||||
for (const c of fs.readdirSync(cmdDir)) {
|
||||
if (c.endsWith('.json')) files.push(`/data/${lang}/services/commands/${c}`);
|
||||
}
|
||||
for (const cmd of ['AfterEach.json', 'Bow.json', 'Prostration.json', 'S1.json', 'S2.json']) {
|
||||
const fp = `/${lang}/services/commands/${cmd}`;
|
||||
if (exists(fp)) files.push(`/data${fp}`);
|
||||
}
|
||||
|
||||
// Dereferenceable service subtrees (Services/CommonPrayers -> prayers, etc.)
|
||||
for (const sub of ['prayers', 'texts', 'headers', 'var']) {
|
||||
const subDir = path.join(DATA_DIR, lang, 'services', sub);
|
||||
if (!fs.existsSync(subDir)) continue;
|
||||
const walk = (d: string, rel: string): void => {
|
||||
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
|
||||
else if (e.name.endsWith('.json')) files.push(`/data/${lang}/services/${sub}${rel}/${e.name}`);
|
||||
}
|
||||
};
|
||||
walk(subDir, '');
|
||||
}
|
||||
|
||||
return files;
|
||||
|
||||
@@ -11,6 +11,16 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
const DATA_DIR = join(__dirname, '..', 'static', 'data');
|
||||
|
||||
/**
|
||||
* Node types allowed in prayer files. Besides TEXT/HEADER content nodes,
|
||||
* imported prayers carry bare classification markers (no payload) that the
|
||||
* assembler intentionally ignores — keep this list in sync with the data.
|
||||
*/
|
||||
const PRAYER_NODE_TYPES = [
|
||||
'TEXT', 'HEADER',
|
||||
'TROPARION', 'KONTAKION', 'THEOTOKION', 'LITANY', 'COMMUNION',
|
||||
];
|
||||
|
||||
interface ValidationError {
|
||||
file: string;
|
||||
path: string;
|
||||
@@ -152,8 +162,8 @@ class Validator {
|
||||
continue;
|
||||
}
|
||||
const obj = node as Record<string, unknown>;
|
||||
if (!obj.type || !['TEXT', 'HEADER', 'TROPARION', 'KONTAKION'].includes(obj.type as string)) {
|
||||
this.addError(path, `[${i}].type`, 'Type must be TEXT or HEADER');
|
||||
if (!obj.type || !PRAYER_NODE_TYPES.includes(obj.type as string)) {
|
||||
this.addError(path, `[${i}].type`, `Type must be one of: ${PRAYER_NODE_TYPES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +273,13 @@ function main() {
|
||||
validator.validateDir(join(enDir, 'services', 'commands'), 'command');
|
||||
}
|
||||
|
||||
// Church Slavonic prayers share the same import format and marker types
|
||||
const cuDir = join(DATA_DIR, 'cu');
|
||||
if (existsSync(cuDir)) {
|
||||
console.log('Validating Church Slavonic data...');
|
||||
validator.validateDir(join(cuDir, 'services', 'prayers'), 'prayer');
|
||||
}
|
||||
|
||||
validator.printReport();
|
||||
}
|
||||
|
||||
|
||||
+17
-7
@@ -13,6 +13,8 @@
|
||||
* const estimate = await DataCache.estimate();
|
||||
*/
|
||||
|
||||
import { diagLog } from './diag-log';
|
||||
|
||||
const DB_NAME = 'ponomar-cache';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'data';
|
||||
@@ -113,11 +115,12 @@ export class DataCache {
|
||||
|
||||
/**
|
||||
* Store a value in the cache.
|
||||
* Resolves true on success; false when the write failed (e.g. quota).
|
||||
*/
|
||||
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<void> {
|
||||
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const entry: CacheEntry = {
|
||||
@@ -128,11 +131,13 @@ export class DataCache {
|
||||
ttl,
|
||||
};
|
||||
store.put(entry);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.oncomplete = () => resolve(true);
|
||||
tx.onerror = () => resolve(false);
|
||||
tx.onabort = () => resolve(false);
|
||||
});
|
||||
} catch {
|
||||
// Silently fail — cache is best-effort
|
||||
// Write failed (e.g. quota exceeded) — report as failure.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +222,11 @@ export class DataCache {
|
||||
const ct = resp.headers.get('content-type');
|
||||
if (resp.ok && !(ct && ct.includes('text/html'))) {
|
||||
const data: unknown = type === 'text' ? await resp.text() : await resp.json();
|
||||
await DataCache.set(`url:${url}`, data);
|
||||
const stored = await DataCache.set(`url:${url}`, data);
|
||||
if (!stored) {
|
||||
// Online view still gets its data; the offline copy is missing.
|
||||
diagLog('idb-write-fail', { url });
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
} catch { /* offline or invalid response — fall through to IDB */ }
|
||||
@@ -258,7 +267,8 @@ export class DataCache {
|
||||
}
|
||||
|
||||
// Cache the fetched data
|
||||
await DataCache.set(cacheKey, data, options?.ttl);
|
||||
const stored = await DataCache.set(cacheKey, data, options?.ttl);
|
||||
if (!stored) diagLog('idb-write-fail', { url });
|
||||
return data as T;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface Translations {
|
||||
error: string;
|
||||
notAvailable: string;
|
||||
|
||||
// App update
|
||||
updateAvailable: string;
|
||||
updateReload: string;
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
calendar: string;
|
||||
@@ -520,6 +524,8 @@ export interface Translations {
|
||||
offlineCalculating: string;
|
||||
offlineDone: string;
|
||||
offlineFailed: string;
|
||||
offlineDegraded: string;
|
||||
offlineMissing: string;
|
||||
offlineSelectLang: string;
|
||||
offlineSelectAll: string;
|
||||
offlineCleared: string;
|
||||
@@ -548,6 +554,9 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
error: 'Error',
|
||||
notAvailable: 'Not available',
|
||||
|
||||
updateAvailable: 'New version available',
|
||||
updateReload: 'Update',
|
||||
|
||||
nav: {
|
||||
calendar: '📅 Calendar',
|
||||
service: '📖 Service',
|
||||
@@ -1090,6 +1099,8 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
offlineCalculating: 'Calculating storage...',
|
||||
offlineDone: 'Done! Data cached for offline use.',
|
||||
offlineFailed: 'files failed to load (retry to attempt again)',
|
||||
offlineDegraded: 'Preload could not read the full file list — a reduced set was cached. Check your connection and try again.',
|
||||
offlineMissing: 'Note: {0} files were not found on the server.',
|
||||
offlineSelectLang: 'Please select at least one language.',
|
||||
offlineSelectAll: 'Select all',
|
||||
offlineCleared: 'Cache cleared.',
|
||||
@@ -1116,6 +1127,9 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
error: 'Ошибка',
|
||||
notAvailable: 'Недоступно',
|
||||
|
||||
updateAvailable: 'Достꙋ́пна новаѧ вєрсі́ѧ',
|
||||
updateReload: 'Обнови́ти',
|
||||
|
||||
nav: {
|
||||
calendar: '📅 Календарь',
|
||||
service: '📖 Устав',
|
||||
@@ -1658,6 +1672,8 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
offlineCalculating: 'Расчёт размера...',
|
||||
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
||||
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
|
||||
offlineDegraded: 'Не удалось загрузить полный список файлов — закэширован сокращённый набор. Проверьте соединение и повторите.',
|
||||
offlineMissing: 'Примечание: {0} файлов не найдено на сервере.',
|
||||
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
||||
offlineSelectAll: 'Выбрать все',
|
||||
offlineCleared: 'Кэш очищен.',
|
||||
@@ -1684,6 +1700,9 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
error: 'Ошибка',
|
||||
notAvailable: 'Недоступно',
|
||||
|
||||
updateAvailable: 'Доступна новая версия',
|
||||
updateReload: 'Обновить',
|
||||
|
||||
nav: {
|
||||
calendar: '📅 Календарь',
|
||||
service: '📖 Уставъ',
|
||||
@@ -2226,6 +2245,8 @@ const translations: Record<LanguageCode, Translations> = {
|
||||
offlineCalculating: 'Расчётъ размѣра...',
|
||||
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
||||
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
|
||||
offlineDegraded: 'Не возмо́жно бѣ̀ прочестѝ по́лный спи́сокъ фа́йловъ — закеши́рованъ сокраще́нный набо́ръ. Провѣ́рьте соедине́нїе и҆ повтори́те.',
|
||||
offlineMissing: 'Примѣча́нїе: {0} фа́йловъ не ѡ҆брѣте́но на се́рверѣ.',
|
||||
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
||||
offlineSelectAll: 'И҆збра́ти всѧ̑',
|
||||
offlineCleared: 'Кешъ очищенъ.',
|
||||
|
||||
+72
-22
@@ -30,6 +30,10 @@ export interface PreloadProgress {
|
||||
done: boolean;
|
||||
failed: number;
|
||||
failedFiles?: string[];
|
||||
/** Files the server answered 404 for (deployment drift). */
|
||||
missing?: number;
|
||||
/** True when the manifest was unavailable and a reduced fallback set ran. */
|
||||
manifestMissing?: boolean;
|
||||
aborted?: boolean;
|
||||
}
|
||||
|
||||
@@ -54,19 +58,17 @@ let manifestPromise: Promise<Record<string, Record<string, string[]>> | null> |
|
||||
|
||||
function loadManifest(): Promise<Record<string, Record<string, string[]>> | null> {
|
||||
if (!manifestPromise) {
|
||||
manifestPromise = (async () => {
|
||||
try {
|
||||
const resp = await fetch('/data/shared/preload-manifest.json');
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})().then((m) => {
|
||||
// Network-first with the IDB fallback: a copy written while online
|
||||
// survives SW-cache eviction, so a later (possibly offline) call can
|
||||
// still read the full file list instead of the reduced fallback set.
|
||||
manifestPromise = DataCache
|
||||
.fetchWithFallback<Record<string, Record<string, string[]>>>('/data/shared/preload-manifest.json')
|
||||
.then((m) => {
|
||||
// Don't cache a failure forever — allow retry on next call
|
||||
if (m === null) {
|
||||
if (m === null || typeof m !== 'object') {
|
||||
diagLog('manifest-unavailable');
|
||||
manifestPromise = null;
|
||||
return null;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
@@ -74,6 +76,24 @@ function loadManifest(): Promise<Record<string, Record<string, string[]>> | null
|
||||
return manifestPromise;
|
||||
}
|
||||
|
||||
/** versions.json loaded once per session (retried on failure). */
|
||||
let bibleVersionsPromise: Promise<any[] | null> | null = null;
|
||||
|
||||
function loadBibleVersions(): Promise<any[] | null> {
|
||||
if (!bibleVersionsPromise) {
|
||||
bibleVersionsPromise = DataCache
|
||||
.fetchWithFallback<any[]>('/data/bible/versions.json')
|
||||
.then((v) => {
|
||||
if (!v || !Array.isArray(v) || v.length === 0) {
|
||||
bibleVersionsPromise = null;
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
return bibleVersionsPromise;
|
||||
}
|
||||
|
||||
function getFallbackCalendarFiles(lang: string): string[] {
|
||||
const files = [
|
||||
`/data/${lang}/menaion-bundle.json`,
|
||||
@@ -89,6 +109,7 @@ function getFallbackCalendarFiles(lang: string): string[] {
|
||||
|
||||
export class OfflineManager {
|
||||
private static _progress: PreloadProgress | null = null;
|
||||
private static _running: Promise<PreloadProgress> | null = null;
|
||||
private static _autoPreloadRequested = false;
|
||||
|
||||
/** Request an automatic "preload everything" once the Settings view opens. */
|
||||
@@ -110,28 +131,21 @@ export class OfflineManager {
|
||||
|
||||
/** Resolve every Bible translation id from versions.json (single source). */
|
||||
static async getAllBibleTranslationIds(): Promise<string[]> {
|
||||
try {
|
||||
const resp = await fetch('/data/bible/versions.json');
|
||||
const versions = await resp.json() as any[];
|
||||
return versions.map((v: any) => v.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const versions = await loadBibleVersions();
|
||||
return versions ? versions.map((v: any) => v.id) : [];
|
||||
}
|
||||
|
||||
/** Discover all book files for a Bible translation from versions.json. */
|
||||
static async getBibleFiles(translationPath: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
try {
|
||||
const resp = await fetch('/data/bible/versions.json');
|
||||
const versions = await resp.json() as any[];
|
||||
const versions = await loadBibleVersions();
|
||||
if (!versions) return files;
|
||||
const ver = versions.find((v: any) => v.id === translationPath);
|
||||
if (ver && ver.books) {
|
||||
for (const book of ver.books) {
|
||||
files.push(`/data/${translationPath}/${book.id}.text`);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -143,8 +157,19 @@ export class OfflineManager {
|
||||
/**
|
||||
* Preload selected data files for offline use.
|
||||
* Returns a PreloadProgress object that can be polled for progress.
|
||||
* Re-entrant calls join the run already in flight.
|
||||
*/
|
||||
static async preload(options: PreloadOptions): Promise<PreloadProgress> {
|
||||
if (OfflineManager._running) return OfflineManager._running;
|
||||
const run = OfflineManager._preloadImpl(options)
|
||||
.finally(() => {
|
||||
if (OfflineManager._running === run) OfflineManager._running = null;
|
||||
});
|
||||
OfflineManager._running = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
private static async _preloadImpl(options: PreloadOptions): Promise<PreloadProgress> {
|
||||
const startedAt = Date.now();
|
||||
const filesToFetch: string[] = [];
|
||||
const m = await loadManifest();
|
||||
@@ -184,8 +209,20 @@ export class OfflineManager {
|
||||
done: false,
|
||||
failed: 0,
|
||||
};
|
||||
if (m === null) progress.manifestMissing = true;
|
||||
OfflineManager._progress = progress;
|
||||
|
||||
// Best-effort: request persistent storage so the OS won't evict data
|
||||
// for LRU pressure (and try to lift legacy quota caps where supported).
|
||||
if (navigator.storage?.persist) {
|
||||
try {
|
||||
const granted = await navigator.storage.persist();
|
||||
diagLog('storage-persist', { granted });
|
||||
} catch {
|
||||
diagLog('storage-persist-error');
|
||||
}
|
||||
}
|
||||
|
||||
diagLog('preload-start', {
|
||||
langs: options.languages.join(','),
|
||||
types: options.types.join(','),
|
||||
@@ -225,8 +262,15 @@ export class OfflineManager {
|
||||
} else {
|
||||
data = await resp.json();
|
||||
}
|
||||
await DataCache.set(`url:${file}`, data);
|
||||
const stored = await DataCache.set(`url:${file}`, data);
|
||||
if (stored) {
|
||||
success = true;
|
||||
} else {
|
||||
// IDB write failed (e.g. quota exceeded) — the cache is not
|
||||
// healthy; count as failure so the circuit breaker can abort
|
||||
// instead of reporting a misleading "done".
|
||||
diagLog('preload-write-fail', { file });
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Retry on network error
|
||||
@@ -244,15 +288,21 @@ export class OfflineManager {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (notFound) progress.missing = (progress.missing ?? 0) + 1;
|
||||
// Success or 404 both prove connectivity
|
||||
netFailStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if ((progress.missing ?? 0) > 0) {
|
||||
diagLog('preload-missing', { count: progress.missing });
|
||||
}
|
||||
|
||||
progress.done = true;
|
||||
diagLog('preload-done', {
|
||||
total: progress.total,
|
||||
failed: progress.failed,
|
||||
missing: progress.missing ?? 0,
|
||||
ms: Date.now() - startedAt,
|
||||
aborted: progress.aborted === true,
|
||||
});
|
||||
|
||||
+27
@@ -1,5 +1,32 @@
|
||||
import './styles/main.css';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { App } from './ui/app';
|
||||
import { getTranslations, type LanguageCode } from './core/i18n';
|
||||
import { loadSettings } from './ui/settings-view';
|
||||
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: () => showUpdateToast(),
|
||||
});
|
||||
|
||||
function showUpdateToast() {
|
||||
if (document.getElementById('pwa-update-toast')) return;
|
||||
const t = getTranslations(loadSettings().language as LanguageCode);
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'pwa-update-toast';
|
||||
toast.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-[60] bg-surface border border-gold/40 rounded-lg shadow-xl px-4 py-3 flex items-center gap-3 max-w-[calc(100vw-2rem)]';
|
||||
toast.innerHTML = `
|
||||
<span class="text-base text-navy whitespace-nowrap">${t.updateAvailable}</span>
|
||||
<button id="pwa-update-btn" class="bg-gold text-navy rounded px-3 py-1 text-base font-bold hover:bg-gold-dark transition-colors whitespace-nowrap">${t.updateReload}</button>
|
||||
<button id="pwa-update-dismiss" class="text-navy-light hover:text-navy text-lg leading-none px-1" aria-label="✕">×</button>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
toast.querySelector('#pwa-update-btn')?.addEventListener('click', () => {
|
||||
(toast.querySelector('#pwa-update-btn') as HTMLButtonElement).disabled = true;
|
||||
updateSW(true);
|
||||
});
|
||||
toast.querySelector('#pwa-update-dismiss')?.addEventListener('click', () => toast.remove());
|
||||
}
|
||||
|
||||
const app = new App(document.getElementById('app')!);
|
||||
app.init();
|
||||
+1
-1
@@ -274,7 +274,7 @@ nav details.nav-group a.nav-link {
|
||||
}
|
||||
nav details.nav-group details a.nav-link {
|
||||
padding: 0.5rem 0.5rem 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Mobile: top-level dropdown as fixed overlay; nested in-flow */
|
||||
|
||||
+4
-4
@@ -190,11 +190,11 @@ export class App {
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-surface rounded-xl shadow-2xl p-6 max-w-md mx-4">
|
||||
<h3 class="text-lg font-bold text-navy mb-2">${t.settings.offlineOfferTitle}</h3>
|
||||
<p class="text-sm text-navy mb-4">${t.settings.offlineOfferText}</p>
|
||||
<p class="text-base text-navy mb-4">${t.settings.offlineOfferText}</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button id="offline-offer-all" class="w-full bg-navy text-parchment rounded-lg px-4 py-2 font-bold hover:bg-navy-light transition-colors">${t.settings.offlineOfferPreloadAll}</button>
|
||||
<button id="offline-offer-choose" class="w-full bg-gold text-navy rounded-lg px-4 py-2 font-bold hover:bg-gold-light transition-colors">${t.settings.offlineOfferChoose}</button>
|
||||
<button id="offline-offer-skip" class="w-full text-sm text-navy-light underline hover:text-navy text-center cursor-pointer">${t.settings.offlineOfferSkip}</button>
|
||||
<button id="offline-offer-skip" class="w-full text-base text-navy-light underline hover:text-navy text-center cursor-pointer">${t.settings.offlineOfferSkip}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -252,10 +252,10 @@ export class App {
|
||||
<img src="/icons/icon-192.png" class="w-6 h-6" alt="">
|
||||
${t.nav.appTitle}
|
||||
</span>
|
||||
<nav class="flex flex-wrap gap-4 text-sm mx-auto">
|
||||
<nav class="flex flex-wrap gap-4 text-base mx-auto">
|
||||
<a href="#calendar" class="nav-link hover:text-gold transition-colors" data-view="calendar">${t.nav.calendar}</a>
|
||||
<details class="nav-group relative" data-nav="library">
|
||||
<summary class="cursor-pointer list-none hover:text-gold transition-colors flex items-center gap-1">${t.nav.library} <span class="text-xs">▾</span></summary>
|
||||
<summary class="cursor-pointer list-none hover:text-gold transition-colors flex items-center gap-1">${t.nav.library} <span class="text-sm">▾</span></summary>
|
||||
<div class="absolute left-0 top-full mt-1 z-50 min-w-56 rounded-lg bg-dropdown-bg shadow-xl p-2 flex flex-col gap-1">
|
||||
<a href="#bible" class="nav-link hover:text-gold transition-colors px-2 py-1" data-view="bible"><span>📖</span> <span class="font-bold">${t.nav.bible}</span></a>
|
||||
<a href="#prayer" class="nav-link hover:text-gold transition-colors px-2 py-1" data-view="prayer">${t.nav.prayer}</a>
|
||||
|
||||
+10
-10
@@ -172,24 +172,24 @@ export class BibleView {
|
||||
this.container.innerHTML = `
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="bg-surface/50 border border-gold/20 rounded-lg p-3 flex items-center gap-4 flex-wrap">
|
||||
<select id="version-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
|
||||
<select id="version-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
|
||||
${this.versions.map(v =>
|
||||
`<option value="${v.id}" ${v.id === this.currentVersion ? 'selected' : ''}>${v.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<select id="book-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
|
||||
<select id="book-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
|
||||
${this.getBooksForVersion().map(b =>
|
||||
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="prev-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">◀</button>
|
||||
<select id="chapter-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-sm">
|
||||
<button id="prev-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20 disabled:opacity-40">◀</button>
|
||||
<select id="chapter-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
|
||||
</select>
|
||||
<button id="next-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20 disabled:opacity-40">▶</button>
|
||||
<button id="next-chapter" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20 disabled:opacity-40">▶</button>
|
||||
</div>
|
||||
<div class="relative ml-auto">
|
||||
<button id="bookmark-btn" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-sm hover:bg-gold/20" title="${this.t.bible.bookmarkAdd}">🔖</button>
|
||||
<button id="bookmark-btn" class="bg-surface/50 text-navy border border-gold/20 rounded px-2 py-1 text-base hover:bg-gold/20" title="${this.t.bible.bookmarkAdd}">🔖</button>
|
||||
<div id="bookmark-panel" class="${this.bookmarksOpen ? 'flex flex-col' : 'hidden'} absolute right-0 top-full mt-1 z-50 min-w-64 rounded-lg bg-dropdown-bg shadow-xl p-2 gap-1 text-header-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,7 +308,7 @@ export class BibleView {
|
||||
const t = this.t.bible;
|
||||
const rows = [...this.bookmarks].sort((a, b) => b.created - a.created).map(b => `
|
||||
<div class="flex items-center rounded px-1 py-1 hover:bg-white/10">
|
||||
<button class="bookmark-jump flex-1 min-w-0 text-left text-sm truncate cursor-pointer py-1 pr-2 rounded active:bg-white/15" data-id="${b.id}" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</button>
|
||||
<button class="bookmark-jump flex-1 min-w-0 text-left text-base truncate cursor-pointer py-1 pr-2 rounded active:bg-white/15" data-id="${b.id}" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</button>
|
||||
<div class="shrink-0 flex items-center gap-1 border-l border-gold/25 pl-2 ml-1">
|
||||
<button class="bookmark-rename w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✏</button>
|
||||
<button class="bookmark-delete w-8 h-8 flex items-center justify-center text-base rounded-md bg-white/5 border border-white/15 active:bg-white/15 cursor-pointer" data-id="${b.id}">✕</button>
|
||||
@@ -317,8 +317,8 @@ export class BibleView {
|
||||
`).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<button id="bookmark-add" class="w-full text-left bg-white/10 border border-gold/40 rounded px-2 py-1.5 text-sm font-bold hover:bg-white/20 cursor-pointer">+ ${t.bookmarkAdd}</button>
|
||||
${rows ? `<div class="border-t border-gold/20 my-1"></div>${rows}` : `<p class="text-xs italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
|
||||
<button id="bookmark-add" class="w-full text-left bg-white/10 border border-gold/40 rounded px-2 py-1.5 text-base font-bold hover:bg-white/20 cursor-pointer">+ ${t.bookmarkAdd}</button>
|
||||
${rows ? `<div class="border-t border-gold/20 my-1"></div>${rows}` : `<p class="text-sm italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
|
||||
`;
|
||||
|
||||
document.getElementById('bookmark-add')?.addEventListener('click', () => {
|
||||
@@ -506,7 +506,7 @@ export class BibleView {
|
||||
textEl.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-navy-light mb-4">${this.t.bible.notAvailableOffline}</p>
|
||||
<p class="text-sm text-navy-light">
|
||||
<p class="text-base text-navy-light">
|
||||
${this.t.bible.ensureDataConverted}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+18
-18
@@ -486,7 +486,7 @@ export class CalendarView {
|
||||
let col = 0;
|
||||
|
||||
gridHtml += this.t.calendar.dayNames.map(d =>
|
||||
`<div class="text-center text-xs font-bold text-red py-1">${d}</div>`
|
||||
`<div class="text-center text-sm font-bold text-red py-1">${d}</div>`
|
||||
).join('');
|
||||
|
||||
for (let i = 0; i < firstDow; i++) {
|
||||
@@ -516,7 +516,7 @@ export class CalendarView {
|
||||
isToday = date.equals(JDate.today());
|
||||
}
|
||||
|
||||
let classes = 'day-cell p-1 text-center text-sm cursor-pointer rounded transition-colors ';
|
||||
let classes = 'day-cell p-1 text-center text-base cursor-pointer rounded transition-colors ';
|
||||
if (isSelected) classes += 'bg-navy text-parchment font-bold ';
|
||||
else if (isToday) classes += 'bg-gold/20 font-bold ';
|
||||
else if (dayRank >= 6) classes += 'bg-red/20 text-red font-bold ';
|
||||
@@ -550,7 +550,7 @@ export class CalendarView {
|
||||
<button id="next-month" class="p-2 hover:bg-navy/10 rounded">▶</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-0.5 text-xs">
|
||||
<div class="grid grid-cols-7 gap-0.5 text-sm">
|
||||
${gridHtml}
|
||||
</div>
|
||||
|
||||
@@ -560,33 +560,33 @@ export class CalendarView {
|
||||
|
||||
<div class="flex-1 p-3 overflow-auto">
|
||||
<div class="max-w-2xl lg:max-w-3xl xl:max-w-4xl mx-auto">
|
||||
<h2 class="text-sm font-bold text-red mb-1">
|
||||
<h2 class="text-base font-bold text-red mb-1">
|
||||
${julianStr}
|
||||
</h2>
|
||||
|
||||
<div id="day-title" class="mb-2"></div>
|
||||
|
||||
<div class="text-sm text-navy-light mb-2">
|
||||
<div class="text-base text-navy-light mb-2">
|
||||
${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr}
|
||||
</div>
|
||||
|
||||
<div id="astro-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg text-sm text-navy">
|
||||
<div id="astro-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg text-base text-navy">
|
||||
<span class="text-navy-light italic">${this.t.loading}</span>
|
||||
</div>
|
||||
|
||||
<div id="commemorations-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
||||
<p class="text-base text-navy-light italic">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="fasting-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.fastingRule}</h3>
|
||||
<p class="text-sm italic text-navy-light">${this.t.loading}</p>
|
||||
<p class="text-base italic text-navy-light">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="readings-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
||||
<p class="text-base text-navy-light italic">${this.t.loading}</p>
|
||||
</div>
|
||||
|
||||
<div id="services-panel" class="mb-2 border-t border-gold/20 pt-3"></div>
|
||||
@@ -652,7 +652,7 @@ export class CalendarView {
|
||||
if (fastingPanel) {
|
||||
fastingPanel.innerHTML = `
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.fastingRule}</h3>
|
||||
<p class="text-sm">${fastingResult.description || this.t.notAvailable}</p>
|
||||
<p class="text-base">${fastingResult.description || this.t.notAvailable}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -742,7 +742,7 @@ export class CalendarView {
|
||||
const toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : '';
|
||||
const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
|
||||
const titleColor = feastName ? 'text-red-700' : 'text-navy';
|
||||
titleEl.innerHTML = `<span class="text-sm font-semibold ${titleColor} italic">${periodName}${toneSuffix}${feastSuffix}</span>`;
|
||||
titleEl.innerHTML = `<span class="text-base font-semibold ${titleColor} italic">${periodName}${toneSuffix}${feastSuffix}</span>`;
|
||||
}
|
||||
|
||||
if (feastCid) this.skipCids.add(feastCid);
|
||||
@@ -754,7 +754,7 @@ export class CalendarView {
|
||||
if (cids.length === 0) {
|
||||
commPanel.innerHTML = `
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
<p class="text-base text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
`;
|
||||
} else {
|
||||
const commHtmlParts: string[] = [];
|
||||
@@ -783,11 +783,11 @@ export class CalendarView {
|
||||
const icon = this.t.calendar.rankIcons[rank] || '';
|
||||
const iconHtml = icon ? `<span class="font-slavonic text-lg mr-1">${icon}</span>` : '';
|
||||
const lifeHtml = lifeText
|
||||
? `<div class="life-text hidden mt-2 p-3 bg-surface border border-gold/20 rounded text-sm leading-relaxed max-h-60 overflow-y-auto">${lifeText}</div>`
|
||||
? `<div class="life-text hidden mt-2 p-3 bg-surface border border-gold/20 rounded text-base leading-relaxed max-h-60 overflow-y-auto">${lifeText}</div>`
|
||||
: '';
|
||||
commHtmlParts.push(
|
||||
`<div class="comm-item">
|
||||
<div class="text-sm font-medium ${lifeText ? 'text-blue-600 underline cursor-pointer hover:text-blue-800' : 'text-navy'}">${iconHtml}${name}${lifeText ? '<span class="comm-chevron text-xs align-middle ml-1 inline-block">▶</span>' : ''}</div>
|
||||
<div class="text-base font-medium ${lifeText ? 'text-blue-600 underline cursor-pointer hover:text-blue-800' : 'text-navy'}">${iconHtml}${name}${lifeText ? '<span class="comm-chevron text-sm align-middle ml-1 inline-block">▶</span>' : ''}</div>
|
||||
${lifeHtml}
|
||||
</div>`
|
||||
);
|
||||
@@ -813,7 +813,7 @@ commPanel.addEventListener('click', (e) => {
|
||||
} else {
|
||||
commPanel.innerHTML = `
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
<p class="text-base text-navy-light italic">${this.t.calendar.noCommemorations}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -897,7 +897,7 @@ commPanel.addEventListener('click', (e) => {
|
||||
if (readingsGroups.length === 0) {
|
||||
readingsPanel.innerHTML = `
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
|
||||
<p class="text-sm text-navy-light italic">${this.t.calendar.noReadings}</p>
|
||||
<p class="text-base text-navy-light italic">${this.t.calendar.noReadings}</p>
|
||||
`;
|
||||
} else {
|
||||
const sorted = [...readingsGroups].sort((a, b) => {
|
||||
@@ -917,7 +917,7 @@ commPanel.addEventListener('click', (e) => {
|
||||
refs += item.purpose;
|
||||
}
|
||||
}
|
||||
return `<div class="text-sm text-navy"><strong>${g.service}</strong>${this.t.calendar.colon}${refs}</div>`;
|
||||
return `<div class="text-base text-navy"><strong>${g.service}</strong>${this.t.calendar.colon}${refs}</div>`;
|
||||
}).join('');
|
||||
readingsPanel.innerHTML = `
|
||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
|
||||
@@ -963,7 +963,7 @@ commPanel.addEventListener('click', (e) => {
|
||||
const svcView = new ServiceView(svcPanel, this.currentDate, this.language);
|
||||
await svcView.render(false);
|
||||
} catch {
|
||||
svcPanel.innerHTML = `<p class="text-sm text-navy-light italic">${this.t.loading}</p>`;
|
||||
svcPanel.innerHTML = `<p class="text-base text-navy-light italic">${this.t.loading}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export class MenaionView {
|
||||
{ id: 'triodion', view: 'triodion', label: this.t.nav.triodion },
|
||||
].map(item => `
|
||||
<button
|
||||
class="sub-nav-chip text-sm transition-colors rounded-lg px-3 py-1
|
||||
class="sub-nav-chip text-base transition-colors rounded-lg px-3 py-1
|
||||
${item.id === 'menaion'
|
||||
? 'border border-gold bg-gold/10 text-navy font-bold'
|
||||
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
|
||||
@@ -119,7 +119,7 @@ export class MenaionView {
|
||||
const key = `${mm}-${dd}`;
|
||||
const n = this.index[key];
|
||||
daysHtml += `
|
||||
<button class="px-1.5 py-0.5 text-xs rounded border transition-colors
|
||||
<button class="px-1.5 py-0.5 text-sm rounded border transition-colors
|
||||
${n ? 'border-gold/40 bg-gold/10 text-navy hover:border-gold cursor-pointer' : 'border-transparent text-navy-light opacity-30 cursor-default'}"
|
||||
data-date="${key}" ${n ? '' : 'disabled'}>
|
||||
${d}
|
||||
@@ -129,9 +129,9 @@ export class MenaionView {
|
||||
|
||||
calEl.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<button id="menaion-prev" class="text-sm text-navy-light hover:text-navy px-2 py-1">◀</button>
|
||||
<button id="menaion-prev" class="text-base text-navy-light hover:text-navy px-2 py-1">◀</button>
|
||||
<span class="text-base font-bold text-navy">${months[month - 1]}</span>
|
||||
<button id="menaion-next" class="text-sm text-navy-light hover:text-navy px-2 py-1">▶</button>
|
||||
<button id="menaion-next" class="text-base text-navy-light hover:text-navy px-2 py-1">▶</button>
|
||||
</div>
|
||||
<div class="grid gap-1 grid-cols-7 mb-2">
|
||||
${daysHtml}
|
||||
|
||||
@@ -191,6 +191,9 @@ export class PrayerView {
|
||||
|
||||
async render() {
|
||||
const sections = this.getSections();
|
||||
if (!this.activeSection) {
|
||||
this.activeSection = sections[0]?.id ?? null;
|
||||
}
|
||||
const isTriodion = this.collection === 'triodion';
|
||||
const subtitle = isTriodion ? this.t.triodion.subtitle
|
||||
: this.collection === 'akathists' ? this.t.akathists.subtitle
|
||||
@@ -218,7 +221,7 @@ export class PrayerView {
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
${subNavItems.map(item => `
|
||||
<button
|
||||
class="sub-nav-chip text-sm transition-colors rounded-lg px-3 py-1
|
||||
class="sub-nav-chip text-base transition-colors rounded-lg px-3 py-1
|
||||
${this.collection === item.id
|
||||
? 'border border-gold bg-gold/10 text-navy font-bold'
|
||||
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
|
||||
@@ -226,8 +229,8 @@ export class PrayerView {
|
||||
>${item.label}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<p class="text-navy-light text-sm mb-2">${subtitle}</p>
|
||||
<select id="prayer-select" class="w-full appearance-none bg-surface text-navy border border-gold/20 rounded-lg px-3 py-2 text-sm mb-3">
|
||||
<p class="text-navy-light text-base mb-2">${subtitle}</p>
|
||||
<select id="prayer-select" class="w-full appearance-none bg-surface text-navy border border-gold/20 rounded-lg px-3 py-2 text-base mb-3">
|
||||
${sections.map(s => `
|
||||
<option value="${s.id}" ${this.activeSection === s.id ? 'selected' : ''}>${s.name}</option>
|
||||
`).join('')}
|
||||
@@ -250,6 +253,8 @@ export class PrayerView {
|
||||
this.activeSection = id;
|
||||
this.loadSection(id);
|
||||
});
|
||||
|
||||
if (this.activeSection) void this.loadSection(this.activeSection);
|
||||
}
|
||||
|
||||
private async loadSection(id: string) {
|
||||
|
||||
+10
-11
@@ -93,7 +93,7 @@ export class ServiceView {
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
${availableServices.map(s => `
|
||||
<button
|
||||
class="service-tab text-sm transition-colors
|
||||
class="service-tab text-base transition-colors
|
||||
${this.activeService === s.id
|
||||
? 'border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1'
|
||||
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1'}"
|
||||
@@ -132,9 +132,9 @@ export class ServiceView {
|
||||
this.container.querySelectorAll('.service-tab').forEach(t => {
|
||||
const id = t.getAttribute('data-service');
|
||||
if (id === serviceId) {
|
||||
t.className = 'service-tab text-sm transition-colors border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1';
|
||||
t.className = 'service-tab text-base transition-colors border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1';
|
||||
} else {
|
||||
t.className = 'service-tab text-sm transition-colors border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1';
|
||||
t.className = 'service-tab text-base transition-colors border border-gold/20 bg-surface/50 text-navy hover:border-gold/50 rounded-lg px-3 py-1';
|
||||
}
|
||||
});
|
||||
this.loadService(service);
|
||||
@@ -921,7 +921,7 @@ export class ServiceView {
|
||||
contentEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-navy-light mb-2">${this.t.services.serviceProducedNoOutput}</p>
|
||||
<p class="text-xs text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
|
||||
<p class="text-sm text-navy-light">${nodes.length} ${this.t.services.nodesAssembled}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -929,21 +929,21 @@ export class ServiceView {
|
||||
contentEl.innerHTML = `
|
||||
<div class="text-center py-8">
|
||||
<p class="text-navy-light mb-2">${this.t.services.errorLoading}</p>
|
||||
<p class="text-xs text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
|
||||
<p class="text-sm text-navy-light">${err instanceof Error ? err.message : this.t.error}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bible translation used for scripture readings inside services.
|
||||
* Only 'en' and 'cu' can occur here: `serviceLang` maps ru→cu, so RU
|
||||
* users read the Slavonic text, matching the rest of the service body.
|
||||
*/
|
||||
private bibleVersion(lang: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'kjv',
|
||||
cu: 'elis',
|
||||
fr: 'ls',
|
||||
el: 'spt',
|
||||
la: 'vulgate',
|
||||
zh: 'cuv',
|
||||
ar: 'svd',
|
||||
};
|
||||
return map[lang] || 'kjv';
|
||||
}
|
||||
@@ -964,7 +964,6 @@ export class ServiceView {
|
||||
|
||||
let text = await tryFetch(this.serviceLang);
|
||||
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
|
||||
if (!text) text = await tryFetch('shared');
|
||||
if (!text) return '';
|
||||
|
||||
const lines = text.split('\n');
|
||||
|
||||
+49
-51
@@ -3,11 +3,26 @@
|
||||
*/
|
||||
|
||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||
import { OfflineManager } from '../core/offline-manager';
|
||||
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: 'Русский' },
|
||||
@@ -44,7 +59,7 @@ interface Settings {
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
language: 'en',
|
||||
cuFont: 'Ponomar',
|
||||
fontSize: 16,
|
||||
fontSize: 18,
|
||||
theme: 'default',
|
||||
defaultBibleVersion: 'kjv',
|
||||
showVerseNumbers: true,
|
||||
@@ -134,7 +149,7 @@ export class SettingsView {
|
||||
|
||||
<!-- Language -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-navy mb-2">${t.settings.language}</label>
|
||||
<label class="block text-base font-bold text-navy mb-2">${t.settings.language}</label>
|
||||
<select id="language" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
|
||||
${LANGUAGES.map(l => `
|
||||
<option value="${l.code}" ${l.code === this.settings.language ? 'selected' : ''}>
|
||||
@@ -172,7 +187,7 @@ export class SettingsView {
|
||||
</div>
|
||||
|
||||
<div class="p-3 bg-parchment-dark rounded border border-gold/20">
|
||||
<p class="text-sm text-navy mb-2">${t.settings.fontPreview}</p>
|
||||
<p class="text-base text-navy mb-2">${t.settings.fontPreview}</p>
|
||||
<div id="font-preview" class="text-lg">
|
||||
Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
|
||||
</div>
|
||||
@@ -181,7 +196,7 @@ export class SettingsView {
|
||||
|
||||
<!-- Font Size -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-bold text-navy mb-2">
|
||||
<label class="block text-base font-bold text-navy mb-2">
|
||||
${t.settings.fontSize} <span id="font-size-value">${this.settings.fontSize}px</span>
|
||||
</label>
|
||||
<input type="range" id="font-size" min="12" max="32" value="${this.settings.fontSize}"
|
||||
@@ -195,12 +210,12 @@ export class SettingsView {
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="calendar-type" value="julian" ${this.settings.calendarType === 'julian' ? 'checked' : ''}
|
||||
class="accent-gold">
|
||||
<span class="text-sm text-navy">${t.settings.julian}</span>
|
||||
<span class="text-base text-navy">${t.settings.julian}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="calendar-type" value="gregorian" ${this.settings.calendarType === 'gregorian' ? 'checked' : ''}
|
||||
class="accent-gold">
|
||||
<span class="text-sm text-navy">${t.settings.gregorian}</span>
|
||||
<span class="text-base text-navy">${t.settings.gregorian}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,21 +225,21 @@ export class SettingsView {
|
||||
<h3 class="text-lg font-bold text-red mb-3">${t.settings.locationTitle}</h3>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-bold text-navy mb-1">${t.settings.latitude}</label>
|
||||
<label class="block text-sm font-bold text-navy mb-1">${t.settings.latitude}</label>
|
||||
<input type="number" id="latitude" step="0.0001" value="${this.settings.latitude}"
|
||||
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-sm">
|
||||
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-base">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-bold text-navy mb-1">${t.settings.longitude}</label>
|
||||
<label class="block text-sm font-bold text-navy mb-1">${t.settings.longitude}</label>
|
||||
<input type="number" id="longitude" step="0.0001" value="${this.settings.longitude}"
|
||||
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-sm">
|
||||
class="w-full border border-gold/30 rounded p-2 bg-surface text-navy text-base">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="request-location" class="bg-navy text-parchment rounded px-3 py-1 text-sm hover:bg-navy-light transition-colors">
|
||||
<button id="request-location" class="bg-navy text-parchment rounded px-3 py-1 text-base hover:bg-navy-light transition-colors">
|
||||
${t.settings.requestLocation}
|
||||
</button>
|
||||
<span class="text-xs text-navy-light">${t.settings.locationNote}</span>
|
||||
<span class="text-sm text-navy-light">${t.settings.locationNote}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,7 +248,7 @@ export class SettingsView {
|
||||
<h3 class="text-lg font-bold text-red mb-3">${t.settings.bibleSettings}</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-bold text-navy mb-1">${t.settings.defaultTranslation}</label>
|
||||
<label class="block text-base font-bold text-navy mb-1">${t.settings.defaultTranslation}</label>
|
||||
<select id="default-bible" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
|
||||
<option value="">${t.settings.offlineCalculating}</option>
|
||||
</select>
|
||||
@@ -242,13 +257,13 @@ export class SettingsView {
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="show-verses" ${this.settings.showVerseNumbers ? 'checked' : ''}
|
||||
class="w-4 h-4 accent-gold">
|
||||
<label for="show-verses" class="text-sm text-navy">${t.settings.showVerseNumbers}</label>
|
||||
<label for="show-verses" class="text-base text-navy">${t.settings.showVerseNumbers}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" id="verse-newline" ${this.settings.verseNewLine ? 'checked' : ''}
|
||||
class="w-4 h-4 accent-gold">
|
||||
<label for="verse-newline" class="text-sm text-navy">${t.settings.verseNewLine}</label>
|
||||
<label for="verse-newline" class="text-base text-navy">${t.settings.verseNewLine}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -257,7 +272,7 @@ export class SettingsView {
|
||||
<button id="install-pwa" class="hidden w-full bg-navy text-parchment rounded p-3 font-bold hover:bg-navy-light transition-colors">
|
||||
${t.settings.installPwa}
|
||||
</button>
|
||||
<p id="install-hint" class="hidden text-sm text-navy-light mt-2"></p>
|
||||
<p id="install-hint" class="hidden text-base text-navy-light mt-2"></p>
|
||||
</div>
|
||||
|
||||
<!-- Offline Content -->
|
||||
@@ -266,11 +281,11 @@ export class SettingsView {
|
||||
|
||||
<!-- Language selection for offline -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-bold text-navy mb-2">${t.settings.offlineLangs}</label>
|
||||
<label class="block text-base font-bold text-navy mb-2">${t.settings.offlineLangs}</label>
|
||||
<div id="offline-langs" class="flex flex-wrap gap-3">
|
||||
${LANGUAGES.map(l => `
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" value="${l.code}" class="offline-lang accent-gold"> <span class="text-sm">${l.local}</span>
|
||||
<input type="checkbox" value="${l.code}" class="offline-lang accent-gold"> <span class="text-base">${l.local}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -278,14 +293,14 @@ export class SettingsView {
|
||||
|
||||
<!-- Data type selection -->
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-bold text-navy mb-2">${t.settings.offlineDataTypes}</label>
|
||||
<label class="block text-base font-bold text-navy mb-2">${t.settings.offlineDataTypes}</label>
|
||||
<div id="offline-types" class="flex flex-wrap gap-3">
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" value="calendar" checked class="offline-type accent-gold"> <span class="text-sm">${t.settings.offlineCalendar}</span>
|
||||
<input type="checkbox" value="calendar" checked class="offline-type accent-gold"> <span class="text-base">${t.settings.offlineCalendar}</span>
|
||||
</label>
|
||||
<div id="bible-row" class="flex items-center gap-1 cursor-pointer">
|
||||
<span class="text-sm">${t.settings.offlineBible} <span id="bible-count" class="text-xs text-navy-light">(0/0)</span></span>
|
||||
<span id="bible-chevron" class="text-xs text-navy-light hover:text-navy select-none">▶</span>
|
||||
<span class="text-base">${t.settings.offlineBible} <span id="bible-count" class="text-sm text-navy-light">(0/0)</span></span>
|
||||
<span id="bible-chevron" class="text-sm text-navy-light hover:text-navy select-none">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bible-translations" class="hidden ml-4 mt-1 space-y-1 border-l border-gold/20 pl-3"></div>
|
||||
@@ -293,17 +308,17 @@ export class SettingsView {
|
||||
|
||||
<!-- Storage info and controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-2">
|
||||
<span id="offline-stats" class="text-sm text-navy-light">${t.settings.offlineCalculating}</span>
|
||||
<span id="offline-stats" class="text-base text-navy-light">${t.settings.offlineCalculating}</span>
|
||||
<div class="flex gap-2">
|
||||
<button id="offline-preload" class="bg-gold text-navy rounded px-3 py-1 text-sm font-bold hover:bg-gold-dark transition-colors">${t.settings.offlinePreload}</button>
|
||||
<button id="offline-clear" class="bg-red text-parchment rounded px-3 py-1 text-sm hover:bg-red-dark transition-colors">${t.settings.offlineClearCache}</button>
|
||||
<button id="offline-preload" class="bg-gold text-navy rounded px-3 py-1 text-base font-bold hover:bg-gold-dark transition-colors">${t.settings.offlinePreload}</button>
|
||||
<button id="offline-clear" class="bg-red text-parchment rounded px-3 py-1 text-base hover:bg-red-dark transition-colors">${t.settings.offlineClearCache}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="offline-progress" class="hidden">
|
||||
<div class="w-full bg-parchment-dark rounded-full h-2 mb-1">
|
||||
<div id="offline-progress-bar" class="bg-gold h-2 rounded-full" style="width: 0%"></div>
|
||||
</div>
|
||||
<p id="offline-progress-text" class="text-xs text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
|
||||
<p id="offline-progress-text" class="text-sm text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
|
||||
</div>
|
||||
<pre id="diag-log" class="text-[10px] font-mono text-navy-light whitespace-pre-wrap max-h-32 overflow-auto mt-1"></pre>
|
||||
</div>
|
||||
@@ -311,14 +326,15 @@ export class SettingsView {
|
||||
<!-- About -->
|
||||
<div class="mt-8 pt-6 border-t border-gold/20">
|
||||
<h3 class="text-lg font-bold text-red mb-2">${t.settings.about}</h3>
|
||||
<p class="text-sm text-navy-light">
|
||||
<p class="text-base text-navy-light">
|
||||
<a href="https://github.com/anttro/ponomar-pwa" target="_blank" rel="noopener" class="underline hover:text-gold">${t.settings.aboutAppName}</a>${t.settings.aboutText}
|
||||
</p>
|
||||
<p class="text-xs text-navy-light mt-2">
|
||||
<p class="text-sm text-navy-light mt-2">
|
||||
<a href="https://github.com/typiconman/ponomar" target="_blank" rel="noopener" class="underline hover:text-gold">
|
||||
${t.settings.aboutLicense}
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-[10px] text-navy-light mt-2 opacity-70">build ${__BUILD_DATE__}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -496,14 +512,14 @@ export class SettingsView {
|
||||
return `
|
||||
<label class="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" value="${v.id}" class="bible-trans accent-gold" ${isCached ? 'checked' : ''}>
|
||||
<span class="text-xs text-navy">${v.name}</span>
|
||||
<span class="text-sm text-navy">${v.name}</span>
|
||||
<span class="text-[10px] text-navy-light">(${OfflineManager.getBibleSize(v.id) || '?'})</span>
|
||||
</label>
|
||||
`;
|
||||
}));
|
||||
container.innerHTML = `
|
||||
<div class="mb-1">
|
||||
<button id="select-all-bible" class="text-xs text-blue-600 underline hover:text-blue-800 cursor-pointer">${t.settings.offlineSelectAll}</button>
|
||||
<button id="select-all-bible" class="text-sm text-blue-600 underline hover:text-blue-800 cursor-pointer">${t.settings.offlineSelectAll}</button>
|
||||
</div>
|
||||
` + html.join('');
|
||||
document.getElementById('select-all-bible')?.addEventListener('click', () => {
|
||||
@@ -562,16 +578,7 @@ export class SettingsView {
|
||||
bibleTranslations: bibleTrans,
|
||||
}).then((prog) => {
|
||||
if (progressBar) progressBar.style.width = '100%';
|
||||
if (progressText) {
|
||||
if (prog.aborted) {
|
||||
progressText.textContent = t.settings.offlineAborted;
|
||||
} else if (prog.failed > 0) {
|
||||
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
||||
progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`;
|
||||
} else {
|
||||
progressText.textContent = t.settings.offlineDone;
|
||||
}
|
||||
}
|
||||
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
|
||||
const diagEl = document.getElementById('diag-log');
|
||||
if (diagEl) diagEl.textContent = diagTail();
|
||||
OfflineManager.getStats().then(stats => {
|
||||
@@ -695,16 +702,7 @@ export class SettingsView {
|
||||
bibleTranslations: 'all',
|
||||
}).then((prog) => {
|
||||
if (progressBar) progressBar.style.width = '100%';
|
||||
if (progressText) {
|
||||
if (prog.aborted) {
|
||||
progressText.textContent = t.settings.offlineAborted;
|
||||
} else if (prog.failed > 0) {
|
||||
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
||||
progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`;
|
||||
} else {
|
||||
progressText.textContent = t.settings.offlineDone;
|
||||
}
|
||||
}
|
||||
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
|
||||
const diagEl = document.getElementById('diag-log');
|
||||
if (diagEl) diagEl.textContent = diagTail();
|
||||
OfflineManager.getStats().then(stats => {
|
||||
|
||||
Vendored
+3
@@ -1 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare const __BUILD_DATE__: string;
|
||||
File diff suppressed because it is too large.
Load diff
+16
-4
@@ -6,15 +6,24 @@ import { VitePWA } from 'vite-plugin-pwa';
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
VitePWA({ registerType: 'prompt',
|
||||
workbox: {
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
skipWaiting: false,
|
||||
clientsClaim: false,
|
||||
globPatterns: ['**/*.{js,css,html,woff,png}'],
|
||||
globIgnores: ['**/data/**'],
|
||||
maximumFileSizeToCacheInBytes: 20 * 1024 * 1024,
|
||||
runtimeCaching: [
|
||||
{
|
||||
// The preload file list must be fresh when online; a stale copy
|
||||
// silently loses coverage. Offline falls back to the SW copy.
|
||||
urlPattern: /\/data\/shared\/preload-manifest\.json$/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'ponomar-manifest',
|
||||
networkTimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/data\/.*\.json$/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
@@ -66,5 +75,8 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
__BUILD_DATE__: JSON.stringify(new Date().toISOString().slice(0, 10)),
|
||||
},
|
||||
publicDir: 'static',
|
||||
});
|
||||
Reference in new issue
Block a user