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
|
- **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`
|
- **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
|
### Core Scripts
|
||||||
- `convert-services.ts` — Service XML to JSON conversion
|
- `convert-services.ts` — Service XML to JSON conversion
|
||||||
- `enrich-lives.ts` — Fuzzy matching and enrichment pipeline (Chetyi-Minei + Bulgakov)
|
- `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
|
- **Bundle CIds must exist**: Menaion CIds require corresponding lives entries
|
||||||
- **Static data is tracked**: `static/data/` in git (except `scripts/output/minei.json`)
|
- **Static data is tracked**: `static/data/` in git (except `scripts/output/minei.json`)
|
||||||
- **Validation must pass**: Changes must pass `tsc --noEmit`, `npm run build`
|
- **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
|
## Key Script Quick Reference
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,27 @@
|
|||||||
- **Forbidden**: Deleting or renaming CIds without proper validation
|
- **Forbidden**: Deleting or renaming CIds without proper validation
|
||||||
- **Forbidden**: Changing JSON structure without updating related code
|
- **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
|
## Validation Requirements
|
||||||
|
|
||||||
### Pre-Commit Checklist
|
### Pre-Commit Checklist
|
||||||
|
|||||||
+2
-2
@@ -51,8 +51,8 @@ static/data/
|
|||||||
│ ├── menaion/ # Daily menaion entries (366 days)
|
│ ├── menaion/ # Daily menaion entries (366 days)
|
||||||
│ ├── calendar/ # Calendar metadata
|
│ ├── calendar/ # Calendar metadata
|
||||||
│ └── services/ # Service templates
|
│ └── services/ # Service templates
|
||||||
├── cu/ # Church Slavonic (similar structure)
|
├── cu/ # Church Slavonic (authoritative calendar + service texts)
|
||||||
├── ru/ # Russian (lives, services)
|
├── ru/ # Russian (enriched lives; services fall back to cu)
|
||||||
├── el/ # Greek (includes mono/)
|
├── el/ # Greek (includes mono/)
|
||||||
├── fr/ # French
|
├── fr/ # French
|
||||||
├── zh/ # Chinese (includes Hans/, Hant/)
|
├── zh/ # Chinese (includes Hans/, Hant/)
|
||||||
|
|||||||
+2
-2
@@ -51,8 +51,8 @@ static/data/
|
|||||||
│ ├── menaion/ # Ежедневные записи минеи (366 дней)
|
│ ├── menaion/ # Ежедневные записи минеи (366 дней)
|
||||||
│ ├── calendar/ # Метаданные календаря
|
│ ├── calendar/ # Метаданные календаря
|
||||||
│ └── services/ # Шаблоны богослужений
|
│ └── services/ # Шаблоны богослужений
|
||||||
├── cu/ # Церковнославянский (аналогичная структура)
|
├── cu/ # Церковнославянский (авторитетный календарь и богослужебные тексты)
|
||||||
├── ru/ # Русский (жития, богослужения)
|
├── ru/ # Русский (обогащённые жития; богослужения берутся из cu)
|
||||||
├── el/ # Греческий (включает mono/)
|
├── el/ # Греческий (включает mono/)
|
||||||
├── fr/ # Французский
|
├── fr/ # Французский
|
||||||
├── zh/ # Китайский (включает Hans/, Hant/)
|
├── zh/ # Китайский (включает Hans/, Hant/)
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ function calendarFiles(lang: string): string[] {
|
|||||||
if (exists(f)) files.push(`/data${f}`);
|
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
|
// Menaion bundle
|
||||||
const mb = `/${lang}/menaion-bundle.json`;
|
const mb = `/${lang}/menaion-bundle.json`;
|
||||||
if (exists(mb)) files.push(`/data${mb}`);
|
if (exists(mb)) files.push(`/data${mb}`);
|
||||||
@@ -123,16 +138,6 @@ function calendarFiles(lang: string): string[] {
|
|||||||
if (exists(fp)) files.push(`/data${fp}`);
|
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
|
// Prayer collections
|
||||||
const prayerDirs: [string, string[]][] = [];
|
const prayerDirs: [string, string[]][] = [];
|
||||||
for (const coll of ['akathists', 'horologion', 'prayer-rule', 'sbornik', 'irmologion']) {
|
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
|
// Parimii
|
||||||
const parimiiDir = path.join(DATA_DIR, 'shared', 'parimii');
|
const parimiiDir = path.join(DATA_DIR, 'shared', 'parimii');
|
||||||
if (fs.existsSync(parimiiDir)) {
|
if (fs.existsSync(parimiiDir)) {
|
||||||
@@ -191,14 +183,26 @@ function calendarFiles(lang: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language-specific service commands
|
// Language-specific service commands (enumerate all on disk)
|
||||||
for (let tone = 1; tone <= 8; tone++) {
|
const cmdDir = path.join(DATA_DIR, lang, 'services', 'commands');
|
||||||
const fp = `/${lang}/services/commands/Tone${tone}.json`;
|
if (fs.existsSync(cmdDir)) {
|
||||||
if (exists(fp)) files.push(`/data${fp}`);
|
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;
|
return files;
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ import { fileURLToPath } from 'url';
|
|||||||
const __dirname = new URL('.', import.meta.url).pathname;
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
const DATA_DIR = join(__dirname, '..', 'static', 'data');
|
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 {
|
interface ValidationError {
|
||||||
file: string;
|
file: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -152,8 +162,8 @@ class Validator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const obj = node as Record<string, unknown>;
|
const obj = node as Record<string, unknown>;
|
||||||
if (!obj.type || !['TEXT', 'HEADER', 'TROPARION', 'KONTAKION'].includes(obj.type as string)) {
|
if (!obj.type || !PRAYER_NODE_TYPES.includes(obj.type as string)) {
|
||||||
this.addError(path, `[${i}].type`, 'Type must be TEXT or HEADER');
|
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');
|
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();
|
validator.printReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-7
@@ -13,6 +13,8 @@
|
|||||||
* const estimate = await DataCache.estimate();
|
* const estimate = await DataCache.estimate();
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { diagLog } from './diag-log';
|
||||||
|
|
||||||
const DB_NAME = 'ponomar-cache';
|
const DB_NAME = 'ponomar-cache';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 1;
|
||||||
const STORE_NAME = 'data';
|
const STORE_NAME = 'data';
|
||||||
@@ -113,11 +115,12 @@ export class DataCache {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a value in the cache.
|
* 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 {
|
try {
|
||||||
const db = await getDB();
|
const db = await getDB();
|
||||||
return new Promise((resolve, reject) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
const store = tx.objectStore(STORE_NAME);
|
const store = tx.objectStore(STORE_NAME);
|
||||||
const entry: CacheEntry = {
|
const entry: CacheEntry = {
|
||||||
@@ -128,11 +131,13 @@ export class DataCache {
|
|||||||
ttl,
|
ttl,
|
||||||
};
|
};
|
||||||
store.put(entry);
|
store.put(entry);
|
||||||
tx.oncomplete = () => resolve();
|
tx.oncomplete = () => resolve(true);
|
||||||
tx.onerror = () => reject(tx.error);
|
tx.onerror = () => resolve(false);
|
||||||
|
tx.onabort = () => resolve(false);
|
||||||
});
|
});
|
||||||
} catch {
|
} 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');
|
const ct = resp.headers.get('content-type');
|
||||||
if (resp.ok && !(ct && ct.includes('text/html'))) {
|
if (resp.ok && !(ct && ct.includes('text/html'))) {
|
||||||
const data: unknown = type === 'text' ? await resp.text() : await resp.json();
|
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;
|
return data as T;
|
||||||
}
|
}
|
||||||
} catch { /* offline or invalid response — fall through to IDB */ }
|
} catch { /* offline or invalid response — fall through to IDB */ }
|
||||||
@@ -258,7 +267,8 @@ export class DataCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the fetched data
|
// 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;
|
return data as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export interface Translations {
|
|||||||
error: string;
|
error: string;
|
||||||
notAvailable: string;
|
notAvailable: string;
|
||||||
|
|
||||||
|
// App update
|
||||||
|
updateAvailable: string;
|
||||||
|
updateReload: string;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
nav: {
|
nav: {
|
||||||
calendar: string;
|
calendar: string;
|
||||||
@@ -520,6 +524,8 @@ export interface Translations {
|
|||||||
offlineCalculating: string;
|
offlineCalculating: string;
|
||||||
offlineDone: string;
|
offlineDone: string;
|
||||||
offlineFailed: string;
|
offlineFailed: string;
|
||||||
|
offlineDegraded: string;
|
||||||
|
offlineMissing: string;
|
||||||
offlineSelectLang: string;
|
offlineSelectLang: string;
|
||||||
offlineSelectAll: string;
|
offlineSelectAll: string;
|
||||||
offlineCleared: string;
|
offlineCleared: string;
|
||||||
@@ -548,6 +554,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
error: 'Error',
|
error: 'Error',
|
||||||
notAvailable: 'Not available',
|
notAvailable: 'Not available',
|
||||||
|
|
||||||
|
updateAvailable: 'New version available',
|
||||||
|
updateReload: 'Update',
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
calendar: '📅 Calendar',
|
calendar: '📅 Calendar',
|
||||||
service: '📖 Service',
|
service: '📖 Service',
|
||||||
@@ -1090,6 +1099,8 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineCalculating: 'Calculating storage...',
|
offlineCalculating: 'Calculating storage...',
|
||||||
offlineDone: 'Done! Data cached for offline use.',
|
offlineDone: 'Done! Data cached for offline use.',
|
||||||
offlineFailed: 'files failed to load (retry to attempt again)',
|
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.',
|
offlineSelectLang: 'Please select at least one language.',
|
||||||
offlineSelectAll: 'Select all',
|
offlineSelectAll: 'Select all',
|
||||||
offlineCleared: 'Cache cleared.',
|
offlineCleared: 'Cache cleared.',
|
||||||
@@ -1116,6 +1127,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
notAvailable: 'Недоступно',
|
notAvailable: 'Недоступно',
|
||||||
|
|
||||||
|
updateAvailable: 'Достꙋ́пна новаѧ вєрсі́ѧ',
|
||||||
|
updateReload: 'Обнови́ти',
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
calendar: '📅 Календарь',
|
calendar: '📅 Календарь',
|
||||||
service: '📖 Устав',
|
service: '📖 Устав',
|
||||||
@@ -1658,6 +1672,8 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineCalculating: 'Расчёт размера...',
|
offlineCalculating: 'Расчёт размера...',
|
||||||
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
||||||
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
|
offlineFailed: 'файлов не загружено (повторите для повторной попытки)',
|
||||||
|
offlineDegraded: 'Не удалось загрузить полный список файлов — закэширован сокращённый набор. Проверьте соединение и повторите.',
|
||||||
|
offlineMissing: 'Примечание: {0} файлов не найдено на сервере.',
|
||||||
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
||||||
offlineSelectAll: 'Выбрать все',
|
offlineSelectAll: 'Выбрать все',
|
||||||
offlineCleared: 'Кэш очищен.',
|
offlineCleared: 'Кэш очищен.',
|
||||||
@@ -1684,6 +1700,9 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
error: 'Ошибка',
|
error: 'Ошибка',
|
||||||
notAvailable: 'Недоступно',
|
notAvailable: 'Недоступно',
|
||||||
|
|
||||||
|
updateAvailable: 'Доступна новая версия',
|
||||||
|
updateReload: 'Обновить',
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
calendar: '📅 Календарь',
|
calendar: '📅 Календарь',
|
||||||
service: '📖 Уставъ',
|
service: '📖 Уставъ',
|
||||||
@@ -2226,6 +2245,8 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineCalculating: 'Расчётъ размѣра...',
|
offlineCalculating: 'Расчётъ размѣра...',
|
||||||
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
||||||
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
|
offlineFailed: 'фа́йловъ не загружено (повтори́те для повторной попытки)',
|
||||||
|
offlineDegraded: 'Не возмо́жно бѣ̀ прочестѝ по́лный спи́сокъ фа́йловъ — закеши́рованъ сокраще́нный набо́ръ. Провѣ́рьте соедине́нїе и҆ повтори́те.',
|
||||||
|
offlineMissing: 'Примѣча́нїе: {0} фа́йловъ не ѡ҆брѣте́но на се́рверѣ.',
|
||||||
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
||||||
offlineSelectAll: 'И҆збра́ти всѧ̑',
|
offlineSelectAll: 'И҆збра́ти всѧ̑',
|
||||||
offlineCleared: 'Кешъ очищенъ.',
|
offlineCleared: 'Кешъ очищенъ.',
|
||||||
|
|||||||
+72
-22
@@ -30,6 +30,10 @@ export interface PreloadProgress {
|
|||||||
done: boolean;
|
done: boolean;
|
||||||
failed: number;
|
failed: number;
|
||||||
failedFiles?: string[];
|
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;
|
aborted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,19 +58,17 @@ let manifestPromise: Promise<Record<string, Record<string, string[]>> | null> |
|
|||||||
|
|
||||||
function loadManifest(): Promise<Record<string, Record<string, string[]>> | null> {
|
function loadManifest(): Promise<Record<string, Record<string, string[]>> | null> {
|
||||||
if (!manifestPromise) {
|
if (!manifestPromise) {
|
||||||
manifestPromise = (async () => {
|
// Network-first with the IDB fallback: a copy written while online
|
||||||
try {
|
// survives SW-cache eviction, so a later (possibly offline) call can
|
||||||
const resp = await fetch('/data/shared/preload-manifest.json');
|
// still read the full file list instead of the reduced fallback set.
|
||||||
if (!resp.ok) return null;
|
manifestPromise = DataCache
|
||||||
return await resp.json();
|
.fetchWithFallback<Record<string, Record<string, string[]>>>('/data/shared/preload-manifest.json')
|
||||||
} catch {
|
.then((m) => {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})().then((m) => {
|
|
||||||
// Don't cache a failure forever — allow retry on next call
|
// Don't cache a failure forever — allow retry on next call
|
||||||
if (m === null) {
|
if (m === null || typeof m !== 'object') {
|
||||||
diagLog('manifest-unavailable');
|
diagLog('manifest-unavailable');
|
||||||
manifestPromise = null;
|
manifestPromise = null;
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
@@ -74,6 +76,24 @@ function loadManifest(): Promise<Record<string, Record<string, string[]>> | null
|
|||||||
return manifestPromise;
|
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[] {
|
function getFallbackCalendarFiles(lang: string): string[] {
|
||||||
const files = [
|
const files = [
|
||||||
`/data/${lang}/menaion-bundle.json`,
|
`/data/${lang}/menaion-bundle.json`,
|
||||||
@@ -89,6 +109,7 @@ function getFallbackCalendarFiles(lang: string): string[] {
|
|||||||
|
|
||||||
export class OfflineManager {
|
export class OfflineManager {
|
||||||
private static _progress: PreloadProgress | null = null;
|
private static _progress: PreloadProgress | null = null;
|
||||||
|
private static _running: Promise<PreloadProgress> | null = null;
|
||||||
private static _autoPreloadRequested = false;
|
private static _autoPreloadRequested = false;
|
||||||
|
|
||||||
/** Request an automatic "preload everything" once the Settings view opens. */
|
/** 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). */
|
/** Resolve every Bible translation id from versions.json (single source). */
|
||||||
static async getAllBibleTranslationIds(): Promise<string[]> {
|
static async getAllBibleTranslationIds(): Promise<string[]> {
|
||||||
try {
|
const versions = await loadBibleVersions();
|
||||||
const resp = await fetch('/data/bible/versions.json');
|
return versions ? versions.map((v: any) => v.id) : [];
|
||||||
const versions = await resp.json() as any[];
|
|
||||||
return versions.map((v: any) => v.id);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Discover all book files for a Bible translation from versions.json. */
|
/** Discover all book files for a Bible translation from versions.json. */
|
||||||
static async getBibleFiles(translationPath: string): Promise<string[]> {
|
static async getBibleFiles(translationPath: string): Promise<string[]> {
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
try {
|
const versions = await loadBibleVersions();
|
||||||
const resp = await fetch('/data/bible/versions.json');
|
if (!versions) return files;
|
||||||
const versions = await resp.json() as any[];
|
|
||||||
const ver = versions.find((v: any) => v.id === translationPath);
|
const ver = versions.find((v: any) => v.id === translationPath);
|
||||||
if (ver && ver.books) {
|
if (ver && ver.books) {
|
||||||
for (const book of ver.books) {
|
for (const book of ver.books) {
|
||||||
files.push(`/data/${translationPath}/${book.id}.text`);
|
files.push(`/data/${translationPath}/${book.id}.text`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +157,19 @@ export class OfflineManager {
|
|||||||
/**
|
/**
|
||||||
* Preload selected data files for offline use.
|
* Preload selected data files for offline use.
|
||||||
* Returns a PreloadProgress object that can be polled for progress.
|
* 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> {
|
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 startedAt = Date.now();
|
||||||
const filesToFetch: string[] = [];
|
const filesToFetch: string[] = [];
|
||||||
const m = await loadManifest();
|
const m = await loadManifest();
|
||||||
@@ -184,8 +209,20 @@ export class OfflineManager {
|
|||||||
done: false,
|
done: false,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
};
|
};
|
||||||
|
if (m === null) progress.manifestMissing = true;
|
||||||
OfflineManager._progress = progress;
|
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', {
|
diagLog('preload-start', {
|
||||||
langs: options.languages.join(','),
|
langs: options.languages.join(','),
|
||||||
types: options.types.join(','),
|
types: options.types.join(','),
|
||||||
@@ -225,8 +262,15 @@ export class OfflineManager {
|
|||||||
} else {
|
} else {
|
||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
}
|
}
|
||||||
await DataCache.set(`url:${file}`, data);
|
const stored = await DataCache.set(`url:${file}`, data);
|
||||||
|
if (stored) {
|
||||||
success = true;
|
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;
|
break;
|
||||||
} catch {
|
} catch {
|
||||||
// Retry on network error
|
// Retry on network error
|
||||||
@@ -244,15 +288,21 @@ export class OfflineManager {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (notFound) progress.missing = (progress.missing ?? 0) + 1;
|
||||||
// Success or 404 both prove connectivity
|
// Success or 404 both prove connectivity
|
||||||
netFailStreak = 0;
|
netFailStreak = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((progress.missing ?? 0) > 0) {
|
||||||
|
diagLog('preload-missing', { count: progress.missing });
|
||||||
|
}
|
||||||
|
|
||||||
progress.done = true;
|
progress.done = true;
|
||||||
diagLog('preload-done', {
|
diagLog('preload-done', {
|
||||||
total: progress.total,
|
total: progress.total,
|
||||||
failed: progress.failed,
|
failed: progress.failed,
|
||||||
|
missing: progress.missing ?? 0,
|
||||||
ms: Date.now() - startedAt,
|
ms: Date.now() - startedAt,
|
||||||
aborted: progress.aborted === true,
|
aborted: progress.aborted === true,
|
||||||
});
|
});
|
||||||
|
|||||||
+27
@@ -1,5 +1,32 @@
|
|||||||
import './styles/main.css';
|
import './styles/main.css';
|
||||||
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import { App } from './ui/app';
|
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')!);
|
const app = new App(document.getElementById('app')!);
|
||||||
app.init();
|
app.init();
|
||||||
+1
-1
@@ -274,7 +274,7 @@ nav details.nav-group a.nav-link {
|
|||||||
}
|
}
|
||||||
nav details.nav-group details a.nav-link {
|
nav details.nav-group details a.nav-link {
|
||||||
padding: 0.5rem 0.5rem 0.5rem 1rem;
|
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 */
|
/* Mobile: top-level dropdown as fixed overlay; nested in-flow */
|
||||||
|
|||||||
+4
-4
@@ -190,11 +190,11 @@ export class App {
|
|||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="bg-surface rounded-xl shadow-2xl p-6 max-w-md mx-4">
|
<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>
|
<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">
|
<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-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-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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -252,10 +252,10 @@ export class App {
|
|||||||
<img src="/icons/icon-192.png" class="w-6 h-6" alt="">
|
<img src="/icons/icon-192.png" class="w-6 h-6" alt="">
|
||||||
${t.nav.appTitle}
|
${t.nav.appTitle}
|
||||||
</span>
|
</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>
|
<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">
|
<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">
|
<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="#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>
|
<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 = `
|
this.container.innerHTML = `
|
||||||
<div class="flex flex-col h-full">
|
<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">
|
<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 =>
|
${this.versions.map(v =>
|
||||||
`<option value="${v.id}" ${v.id === this.currentVersion ? 'selected' : ''}>${v.name}</option>`
|
`<option value="${v.id}" ${v.id === this.currentVersion ? 'selected' : ''}>${v.name}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select>
|
</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 =>
|
${this.getBooksForVersion().map(b =>
|
||||||
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
|
`<option value="${b.id}" ${b.id === this.currentBook ? 'selected' : ''}>${b.name}</option>`
|
||||||
).join('')}
|
).join('')}
|
||||||
</select>
|
</select>
|
||||||
<div class="flex items-center gap-1">
|
<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>
|
<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-sm">
|
<select id="chapter-select" class="appearance-none bg-surface text-navy border border-gold/20 rounded px-2 py-1 text-base">
|
||||||
</select>
|
</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>
|
||||||
<div class="relative ml-auto">
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -308,7 +308,7 @@ export class BibleView {
|
|||||||
const t = this.t.bible;
|
const t = this.t.bible;
|
||||||
const rows = [...this.bookmarks].sort((a, b) => b.created - a.created).map(b => `
|
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">
|
<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">
|
<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-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>
|
<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('');
|
`).join('');
|
||||||
|
|
||||||
panel.innerHTML = `
|
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>
|
<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-xs italic opacity-70 px-2 py-1">${t.bookmarkEmpty}</p>`}
|
${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', () => {
|
document.getElementById('bookmark-add')?.addEventListener('click', () => {
|
||||||
@@ -506,7 +506,7 @@ export class BibleView {
|
|||||||
textEl.innerHTML = `
|
textEl.innerHTML = `
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-navy-light mb-4">${this.t.bible.notAvailableOffline}</p>
|
<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}
|
${this.t.bible.ensureDataConverted}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+18
-18
@@ -486,7 +486,7 @@ export class CalendarView {
|
|||||||
let col = 0;
|
let col = 0;
|
||||||
|
|
||||||
gridHtml += this.t.calendar.dayNames.map(d =>
|
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('');
|
).join('');
|
||||||
|
|
||||||
for (let i = 0; i < firstDow; i++) {
|
for (let i = 0; i < firstDow; i++) {
|
||||||
@@ -516,7 +516,7 @@ export class CalendarView {
|
|||||||
isToday = date.equals(JDate.today());
|
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 ';
|
if (isSelected) classes += 'bg-navy text-parchment font-bold ';
|
||||||
else if (isToday) classes += 'bg-gold/20 font-bold ';
|
else if (isToday) classes += 'bg-gold/20 font-bold ';
|
||||||
else if (dayRank >= 6) classes += 'bg-red/20 text-red 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>
|
<button id="next-month" class="p-2 hover:bg-navy/10 rounded">▶</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 gap-0.5 text-xs">
|
<div class="grid grid-cols-7 gap-0.5 text-sm">
|
||||||
${gridHtml}
|
${gridHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -560,33 +560,33 @@ export class CalendarView {
|
|||||||
|
|
||||||
<div class="flex-1 p-3 overflow-auto">
|
<div class="flex-1 p-3 overflow-auto">
|
||||||
<div class="max-w-2xl lg:max-w-3xl xl:max-w-4xl mx-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}
|
${julianStr}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div id="day-title" class="mb-2"></div>
|
<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}
|
${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr}
|
||||||
</div>
|
</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>
|
<span class="text-navy-light italic">${this.t.loading}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="commemorations-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div id="fasting-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div id="readings-panel" class="mb-2 p-2 bg-surface/50 border border-gold/20 rounded-lg ${fnClass}">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div id="services-panel" class="mb-2 border-t border-gold/20 pt-3"></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) {
|
if (fastingPanel) {
|
||||||
fastingPanel.innerHTML = `
|
fastingPanel.innerHTML = `
|
||||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.fastingRule}</h3>
|
<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 toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : '';
|
||||||
const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
|
const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : '';
|
||||||
const titleColor = feastName ? 'text-red-700' : 'text-navy';
|
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);
|
if (feastCid) this.skipCids.add(feastCid);
|
||||||
@@ -754,7 +754,7 @@ export class CalendarView {
|
|||||||
if (cids.length === 0) {
|
if (cids.length === 0) {
|
||||||
commPanel.innerHTML = `
|
commPanel.innerHTML = `
|
||||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
|
<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 {
|
} else {
|
||||||
const commHtmlParts: string[] = [];
|
const commHtmlParts: string[] = [];
|
||||||
@@ -783,11 +783,11 @@ export class CalendarView {
|
|||||||
const icon = this.t.calendar.rankIcons[rank] || '';
|
const icon = this.t.calendar.rankIcons[rank] || '';
|
||||||
const iconHtml = icon ? `<span class="font-slavonic text-lg mr-1">${icon}</span>` : '';
|
const iconHtml = icon ? `<span class="font-slavonic text-lg mr-1">${icon}</span>` : '';
|
||||||
const lifeHtml = lifeText
|
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(
|
commHtmlParts.push(
|
||||||
`<div class="comm-item">
|
`<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}
|
${lifeHtml}
|
||||||
</div>`
|
</div>`
|
||||||
);
|
);
|
||||||
@@ -813,7 +813,7 @@ commPanel.addEventListener('click', (e) => {
|
|||||||
} else {
|
} else {
|
||||||
commPanel.innerHTML = `
|
commPanel.innerHTML = `
|
||||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.commemorations}</h3>
|
<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) {
|
if (readingsGroups.length === 0) {
|
||||||
readingsPanel.innerHTML = `
|
readingsPanel.innerHTML = `
|
||||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
|
<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 {
|
} else {
|
||||||
const sorted = [...readingsGroups].sort((a, b) => {
|
const sorted = [...readingsGroups].sort((a, b) => {
|
||||||
@@ -917,7 +917,7 @@ commPanel.addEventListener('click', (e) => {
|
|||||||
refs += item.purpose;
|
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('');
|
}).join('');
|
||||||
readingsPanel.innerHTML = `
|
readingsPanel.innerHTML = `
|
||||||
<h3 class="font-bold text-red mb-2">${this.t.calendar.readings}</h3>
|
<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);
|
const svcView = new ServiceView(svcPanel, this.currentDate, this.language);
|
||||||
await svcView.render(false);
|
await svcView.render(false);
|
||||||
} catch {
|
} 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 },
|
{ id: 'triodion', view: 'triodion', label: this.t.nav.triodion },
|
||||||
].map(item => `
|
].map(item => `
|
||||||
<button
|
<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'
|
${item.id === 'menaion'
|
||||||
? 'border border-gold bg-gold/10 text-navy font-bold'
|
? 'border border-gold bg-gold/10 text-navy font-bold'
|
||||||
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
|
: '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 key = `${mm}-${dd}`;
|
||||||
const n = this.index[key];
|
const n = this.index[key];
|
||||||
daysHtml += `
|
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'}"
|
${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'}>
|
data-date="${key}" ${n ? '' : 'disabled'}>
|
||||||
${d}
|
${d}
|
||||||
@@ -129,9 +129,9 @@ export class MenaionView {
|
|||||||
|
|
||||||
calEl.innerHTML = `
|
calEl.innerHTML = `
|
||||||
<div class="flex items-center justify-between mb-3">
|
<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>
|
<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>
|
||||||
<div class="grid gap-1 grid-cols-7 mb-2">
|
<div class="grid gap-1 grid-cols-7 mb-2">
|
||||||
${daysHtml}
|
${daysHtml}
|
||||||
|
|||||||
@@ -191,6 +191,9 @@ export class PrayerView {
|
|||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
const sections = this.getSections();
|
const sections = this.getSections();
|
||||||
|
if (!this.activeSection) {
|
||||||
|
this.activeSection = sections[0]?.id ?? null;
|
||||||
|
}
|
||||||
const isTriodion = this.collection === 'triodion';
|
const isTriodion = this.collection === 'triodion';
|
||||||
const subtitle = isTriodion ? this.t.triodion.subtitle
|
const subtitle = isTriodion ? this.t.triodion.subtitle
|
||||||
: this.collection === 'akathists' ? this.t.akathists.subtitle
|
: this.collection === 'akathists' ? this.t.akathists.subtitle
|
||||||
@@ -218,7 +221,7 @@ export class PrayerView {
|
|||||||
<div class="flex flex-wrap gap-2 mb-2">
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
${subNavItems.map(item => `
|
${subNavItems.map(item => `
|
||||||
<button
|
<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
|
${this.collection === item.id
|
||||||
? 'border border-gold bg-gold/10 text-navy font-bold'
|
? 'border border-gold bg-gold/10 text-navy font-bold'
|
||||||
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
|
: 'border border-gold/20 bg-surface/50 text-navy hover:border-gold/50'}"
|
||||||
@@ -226,8 +229,8 @@ export class PrayerView {
|
|||||||
>${item.label}</button>
|
>${item.label}</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-navy-light text-sm mb-2">${subtitle}</p>
|
<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-sm mb-3">
|
<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 => `
|
${sections.map(s => `
|
||||||
<option value="${s.id}" ${this.activeSection === s.id ? 'selected' : ''}>${s.name}</option>
|
<option value="${s.id}" ${this.activeSection === s.id ? 'selected' : ''}>${s.name}</option>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -250,6 +253,8 @@ export class PrayerView {
|
|||||||
this.activeSection = id;
|
this.activeSection = id;
|
||||||
this.loadSection(id);
|
this.loadSection(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.activeSection) void this.loadSection(this.activeSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSection(id: string) {
|
private async loadSection(id: string) {
|
||||||
|
|||||||
+10
-11
@@ -93,7 +93,7 @@ export class ServiceView {
|
|||||||
<div class="flex flex-wrap gap-2 mb-3">
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
${availableServices.map(s => `
|
${availableServices.map(s => `
|
||||||
<button
|
<button
|
||||||
class="service-tab text-sm transition-colors
|
class="service-tab text-base transition-colors
|
||||||
${this.activeService === s.id
|
${this.activeService === s.id
|
||||||
? 'border border-gold bg-gold/10 text-navy font-bold rounded-lg px-3 py-1'
|
? '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'}"
|
: '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 => {
|
this.container.querySelectorAll('.service-tab').forEach(t => {
|
||||||
const id = t.getAttribute('data-service');
|
const id = t.getAttribute('data-service');
|
||||||
if (id === serviceId) {
|
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 {
|
} 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);
|
this.loadService(service);
|
||||||
@@ -921,7 +921,7 @@ export class ServiceView {
|
|||||||
contentEl.innerHTML = `
|
contentEl.innerHTML = `
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-navy-light mb-2">${this.t.services.serviceProducedNoOutput}</p>
|
<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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -929,21 +929,21 @@ export class ServiceView {
|
|||||||
contentEl.innerHTML = `
|
contentEl.innerHTML = `
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-navy-light mb-2">${this.t.services.errorLoading}</p>
|
<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>
|
</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 {
|
private bibleVersion(lang: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
en: 'kjv',
|
en: 'kjv',
|
||||||
cu: 'elis',
|
cu: 'elis',
|
||||||
fr: 'ls',
|
|
||||||
el: 'spt',
|
|
||||||
la: 'vulgate',
|
|
||||||
zh: 'cuv',
|
|
||||||
ar: 'svd',
|
|
||||||
};
|
};
|
||||||
return map[lang] || 'kjv';
|
return map[lang] || 'kjv';
|
||||||
}
|
}
|
||||||
@@ -964,7 +964,6 @@ export class ServiceView {
|
|||||||
|
|
||||||
let text = await tryFetch(this.serviceLang);
|
let text = await tryFetch(this.serviceLang);
|
||||||
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
|
if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu');
|
||||||
if (!text) text = await tryFetch('shared');
|
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
|
|||||||
+49
-51
@@ -3,11 +3,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
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 { DataCache } from '../core/data-cache';
|
||||||
import { diagTail } from '../core/diag-log';
|
import { diagTail } from '../core/diag-log';
|
||||||
import { isPwaInstalled } from '../core/pwa-install';
|
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 = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', name: 'English', local: 'English' },
|
{ code: 'en', name: 'English', local: 'English' },
|
||||||
{ code: 'ru', name: 'Russian', local: 'Русский' },
|
{ code: 'ru', name: 'Russian', local: 'Русский' },
|
||||||
@@ -44,7 +59,7 @@ interface Settings {
|
|||||||
const DEFAULT_SETTINGS: Settings = {
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
language: 'en',
|
language: 'en',
|
||||||
cuFont: 'Ponomar',
|
cuFont: 'Ponomar',
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
theme: 'default',
|
theme: 'default',
|
||||||
defaultBibleVersion: 'kjv',
|
defaultBibleVersion: 'kjv',
|
||||||
showVerseNumbers: true,
|
showVerseNumbers: true,
|
||||||
@@ -134,7 +149,7 @@ export class SettingsView {
|
|||||||
|
|
||||||
<!-- Language -->
|
<!-- Language -->
|
||||||
<div class="mb-6">
|
<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">
|
<select id="language" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
|
||||||
${LANGUAGES.map(l => `
|
${LANGUAGES.map(l => `
|
||||||
<option value="${l.code}" ${l.code === this.settings.language ? 'selected' : ''}>
|
<option value="${l.code}" ${l.code === this.settings.language ? 'selected' : ''}>
|
||||||
@@ -172,7 +187,7 @@ export class SettingsView {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-3 bg-parchment-dark rounded border border-gold/20">
|
<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 id="font-preview" class="text-lg">
|
||||||
Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
|
Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +196,7 @@ export class SettingsView {
|
|||||||
|
|
||||||
<!-- Font Size -->
|
<!-- Font Size -->
|
||||||
<div class="mb-6">
|
<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>
|
${t.settings.fontSize} <span id="font-size-value">${this.settings.fontSize}px</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="range" id="font-size" min="12" max="32" value="${this.settings.fontSize}"
|
<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">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="radio" name="calendar-type" value="julian" ${this.settings.calendarType === 'julian' ? 'checked' : ''}
|
<input type="radio" name="calendar-type" value="julian" ${this.settings.calendarType === 'julian' ? 'checked' : ''}
|
||||||
class="accent-gold">
|
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>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="radio" name="calendar-type" value="gregorian" ${this.settings.calendarType === 'gregorian' ? 'checked' : ''}
|
<input type="radio" name="calendar-type" value="gregorian" ${this.settings.calendarType === 'gregorian' ? 'checked' : ''}
|
||||||
class="accent-gold">
|
class="accent-gold">
|
||||||
<span class="text-sm text-navy">${t.settings.gregorian}</span>
|
<span class="text-base text-navy">${t.settings.gregorian}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,21 +225,21 @@ export class SettingsView {
|
|||||||
<h3 class="text-lg font-bold text-red mb-3">${t.settings.locationTitle}</h3>
|
<h3 class="text-lg font-bold text-red mb-3">${t.settings.locationTitle}</h3>
|
||||||
<div class="flex gap-2 mb-2">
|
<div class="flex gap-2 mb-2">
|
||||||
<div class="flex-1">
|
<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}"
|
<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>
|
||||||
<div class="flex-1">
|
<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}"
|
<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>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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}
|
${t.settings.requestLocation}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,7 +248,7 @@ export class SettingsView {
|
|||||||
<h3 class="text-lg font-bold text-red mb-3">${t.settings.bibleSettings}</h3>
|
<h3 class="text-lg font-bold text-red mb-3">${t.settings.bibleSettings}</h3>
|
||||||
|
|
||||||
<div class="mb-3">
|
<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">
|
<select id="default-bible" class="w-full border border-gold/30 rounded p-2 bg-surface text-navy">
|
||||||
<option value="">${t.settings.offlineCalculating}</option>
|
<option value="">${t.settings.offlineCalculating}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -242,13 +257,13 @@ export class SettingsView {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="show-verses" ${this.settings.showVerseNumbers ? 'checked' : ''}
|
<input type="checkbox" id="show-verses" ${this.settings.showVerseNumbers ? 'checked' : ''}
|
||||||
class="w-4 h-4 accent-gold">
|
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input type="checkbox" id="verse-newline" ${this.settings.verseNewLine ? 'checked' : ''}
|
<input type="checkbox" id="verse-newline" ${this.settings.verseNewLine ? 'checked' : ''}
|
||||||
class="w-4 h-4 accent-gold">
|
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>
|
||||||
</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">
|
<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}
|
${t.settings.installPwa}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Offline Content -->
|
<!-- Offline Content -->
|
||||||
@@ -266,11 +281,11 @@ export class SettingsView {
|
|||||||
|
|
||||||
<!-- Language selection for offline -->
|
<!-- Language selection for offline -->
|
||||||
<div class="mb-3">
|
<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">
|
<div id="offline-langs" class="flex flex-wrap gap-3">
|
||||||
${LANGUAGES.map(l => `
|
${LANGUAGES.map(l => `
|
||||||
<label class="flex items-center gap-1 cursor-pointer">
|
<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>
|
</label>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,14 +293,14 @@ export class SettingsView {
|
|||||||
|
|
||||||
<!-- Data type selection -->
|
<!-- Data type selection -->
|
||||||
<div class="mb-3">
|
<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">
|
<div id="offline-types" class="flex flex-wrap gap-3">
|
||||||
<label class="flex items-center gap-1 cursor-pointer">
|
<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>
|
</label>
|
||||||
<div id="bible-row" class="flex items-center gap-1 cursor-pointer">
|
<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 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-xs text-navy-light hover:text-navy select-none">▶</span>
|
<span id="bible-chevron" class="text-sm text-navy-light hover:text-navy select-none">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bible-translations" class="hidden ml-4 mt-1 space-y-1 border-l border-gold/20 pl-3"></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 -->
|
<!-- Storage info and controls -->
|
||||||
<div class="flex items-center justify-between gap-3 mb-2">
|
<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">
|
<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-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-sm hover:bg-red-dark transition-colors">${t.settings.offlineClearCache}</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>
|
</div>
|
||||||
<div id="offline-progress" class="hidden">
|
<div id="offline-progress" class="hidden">
|
||||||
<div class="w-full bg-parchment-dark rounded-full h-2 mb-1">
|
<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 id="offline-progress-bar" class="bg-gold h-2 rounded-full" style="width: 0%"></div>
|
||||||
</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>
|
</div>
|
||||||
<pre id="diag-log" class="text-[10px] font-mono text-navy-light whitespace-pre-wrap max-h-32 overflow-auto mt-1"></pre>
|
<pre id="diag-log" class="text-[10px] font-mono text-navy-light whitespace-pre-wrap max-h-32 overflow-auto mt-1"></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,14 +326,15 @@ export class SettingsView {
|
|||||||
<!-- About -->
|
<!-- About -->
|
||||||
<div class="mt-8 pt-6 border-t border-gold/20">
|
<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>
|
<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}
|
<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>
|
||||||
<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">
|
<a href="https://github.com/typiconman/ponomar" target="_blank" rel="noopener" class="underline hover:text-gold">
|
||||||
${t.settings.aboutLicense}
|
${t.settings.aboutLicense}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-[10px] text-navy-light mt-2 opacity-70">build ${__BUILD_DATE__}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -496,14 +512,14 @@ export class SettingsView {
|
|||||||
return `
|
return `
|
||||||
<label class="flex items-center gap-1 cursor-pointer">
|
<label class="flex items-center gap-1 cursor-pointer">
|
||||||
<input type="checkbox" value="${v.id}" class="bible-trans accent-gold" ${isCached ? 'checked' : ''}>
|
<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>
|
<span class="text-[10px] text-navy-light">(${OfflineManager.getBibleSize(v.id) || '?'})</span>
|
||||||
</label>
|
</label>
|
||||||
`;
|
`;
|
||||||
}));
|
}));
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="mb-1">
|
<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>
|
</div>
|
||||||
` + html.join('');
|
` + html.join('');
|
||||||
document.getElementById('select-all-bible')?.addEventListener('click', () => {
|
document.getElementById('select-all-bible')?.addEventListener('click', () => {
|
||||||
@@ -562,16 +578,7 @@ export class SettingsView {
|
|||||||
bibleTranslations: bibleTrans,
|
bibleTranslations: bibleTrans,
|
||||||
}).then((prog) => {
|
}).then((prog) => {
|
||||||
if (progressBar) progressBar.style.width = '100%';
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
if (progressText) {
|
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const diagEl = document.getElementById('diag-log');
|
const diagEl = document.getElementById('diag-log');
|
||||||
if (diagEl) diagEl.textContent = diagTail();
|
if (diagEl) diagEl.textContent = diagTail();
|
||||||
OfflineManager.getStats().then(stats => {
|
OfflineManager.getStats().then(stats => {
|
||||||
@@ -695,16 +702,7 @@ export class SettingsView {
|
|||||||
bibleTranslations: 'all',
|
bibleTranslations: 'all',
|
||||||
}).then((prog) => {
|
}).then((prog) => {
|
||||||
if (progressBar) progressBar.style.width = '100%';
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
if (progressText) {
|
if (progressText) progressText.textContent = preloadResultText(prog, t.settings);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const diagEl = document.getElementById('diag-log');
|
const diagEl = document.getElementById('diag-log');
|
||||||
if (diagEl) diagEl.textContent = diagTail();
|
if (diagEl) diagEl.textContent = diagTail();
|
||||||
OfflineManager.getStats().then(stats => {
|
OfflineManager.getStats().then(stats => {
|
||||||
|
|||||||
Vendored
+3
@@ -1 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <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({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({ registerType: 'prompt',
|
||||||
registerType: 'autoUpdate',
|
|
||||||
workbox: {
|
workbox: {
|
||||||
skipWaiting: true,
|
skipWaiting: false,
|
||||||
clientsClaim: true,
|
clientsClaim: false,
|
||||||
globPatterns: ['**/*.{js,css,html,woff,png}'],
|
globPatterns: ['**/*.{js,css,html,woff,png}'],
|
||||||
globIgnores: ['**/data/**'],
|
globIgnores: ['**/data/**'],
|
||||||
maximumFileSizeToCacheInBytes: 20 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 20 * 1024 * 1024,
|
||||||
runtimeCaching: [
|
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$/,
|
urlPattern: /\/data\/.*\.json$/,
|
||||||
handler: 'StaleWhileRevalidate',
|
handler: 'StaleWhileRevalidate',
|
||||||
@@ -66,5 +75,8 @@ export default defineConfig({
|
|||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
__BUILD_DATE__: JSON.stringify(new Date().toISOString().slice(0, 10)),
|
||||||
|
},
|
||||||
publicDir: 'static',
|
publicDir: 'static',
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user