feat: moon phase + sunrise/sunset with location settings
This commit is contained in:
1 parent
5dd8a947c6
commit
672af4b5fb
4 files changed
+271
No files matched your search
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Astronomical calculations for sunrise/sunset times and moon phase.
|
||||||
|
* All calculations are client-side, no external APIs required.
|
||||||
|
*
|
||||||
|
* Sunrise/sunset algorithm based on NOAA Solar Calculator.
|
||||||
|
* Moon phase uses the existing Paschalion lunar calculation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { JDate } from './jdate';
|
||||||
|
import { Paschalion } from './paschalion';
|
||||||
|
|
||||||
|
/** Result of sunrise/sunset calculation for a given day. */
|
||||||
|
export interface SolarTimes {
|
||||||
|
sunrise: string; // "06:34"
|
||||||
|
sunset: string; // "20:15"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moon phase info for a given day. */
|
||||||
|
export interface LunarInfo {
|
||||||
|
phase: number; // 0–1 (0=new, 0.5=full)
|
||||||
|
emoji: string; // 🌑🌒🌓🌔🌕🌖🌗🌘
|
||||||
|
name: string; // localized phase name (set by caller)
|
||||||
|
illumination: number; // 0–100 (percentage visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate sunrise and sunset times for a given Julian date and location.
|
||||||
|
* @param jdate Julian date (time is treated as noon)
|
||||||
|
* @param lat Latitude in degrees (north positive)
|
||||||
|
* @param lon Longitude in degrees (east positive)
|
||||||
|
* @param tz Timezone offset from UTC in hours (default: 3 for Moscow)
|
||||||
|
*/
|
||||||
|
export function calcSolarTimes(jdate: JDate, lat: number, lon: number, tz: number = 3): SolarTimes | null {
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
|
||||||
|
// Julian Day Number at noon (UT)
|
||||||
|
const jdn = jdate.jdn + 0.5;
|
||||||
|
|
||||||
|
// Julian centuries since J2000.0
|
||||||
|
const T = (jdn - 2451545.0) / 36525;
|
||||||
|
|
||||||
|
// Solar mean anomaly (degrees)
|
||||||
|
const M = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
|
||||||
|
|
||||||
|
// Equation of center
|
||||||
|
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(degToRad(M))
|
||||||
|
+ (0.019993 - 0.000101 * T) * Math.sin(degToRad(2 * M))
|
||||||
|
+ 0.000289 * Math.sin(degToRad(3 * M));
|
||||||
|
|
||||||
|
// Ecliptic longitude (degrees)
|
||||||
|
const lambda = M + C + 180 + 102.9372; // +180 for true longitude, +102.9372 for perihelion
|
||||||
|
|
||||||
|
// Obliquity of ecliptic
|
||||||
|
const epsilon = 23.439291 - 0.0130042 * T;
|
||||||
|
|
||||||
|
// Solar declination (degrees)
|
||||||
|
const sinEcl = Math.sin(degToRad(epsilon));
|
||||||
|
const ySin = sinEcl * Math.sin(degToRad(lambda));
|
||||||
|
const dec = radToDeg(Math.asin(ySin));
|
||||||
|
|
||||||
|
// Equation of time (minutes)
|
||||||
|
const y = Math.tan(degToRad(epsilon / 2));
|
||||||
|
const y2 = y * y;
|
||||||
|
const sin2L = Math.sin(degToRad(2 * lambda));
|
||||||
|
const sinM = Math.sin(degToRad(M));
|
||||||
|
const cos2L = Math.cos(degToRad(2 * lambda));
|
||||||
|
const cos2M = Math.cos(degToRad(2 * M));
|
||||||
|
const EoT = 4 * radToDeg(
|
||||||
|
y2 * sin2L - 2 * y * sinM + 4 * y * y2 * sinM * cos2L
|
||||||
|
- 0.5 * y2 * y2 * cos2L - 1.25 * y2 * cos2M
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hour angle (degrees) — cosH = (sin(-0.833) - sin(lat)*sin(dec)) / (cos(lat)*cos(dec))
|
||||||
|
const latRad = degToRad(lat);
|
||||||
|
const decRad = degToRad(dec);
|
||||||
|
const cosH = (Math.sin(degToRad(-0.833)) - Math.sin(latRad) * Math.sin(decRad))
|
||||||
|
/ (Math.cos(latRad) * Math.cos(decRad));
|
||||||
|
|
||||||
|
// Check for polar day/night
|
||||||
|
if (cosH < -1) return { sunrise: '—', sunset: '—' }; // polar night (sun never rises)
|
||||||
|
if (cosH > 1) return { sunrise: '—', sunset: '—' }; // midnight sun (sun never sets)
|
||||||
|
|
||||||
|
const H = radToDeg(Math.acos(cosH));
|
||||||
|
|
||||||
|
// UTC time of sunrise/sunset (hours)
|
||||||
|
const sunriseUTC = 12 - H / 15 - EoT / 60 - lon / 15;
|
||||||
|
const sunsetUTC = 12 + H / 15 - EoT / 60 - lon / 15;
|
||||||
|
|
||||||
|
// Convert to local timezone
|
||||||
|
const sunriseLocal = sunriseUTC + tz;
|
||||||
|
const sunsetLocal = sunsetUTC + tz;
|
||||||
|
|
||||||
|
// Format as HH:MM
|
||||||
|
const fmt = (hours: number): string => {
|
||||||
|
const h = Math.floor(((hours % 24) + 24) % 24);
|
||||||
|
const m = Math.floor((hours - Math.floor(hours)) * 60 + 0.5);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sunrise: fmt(sunriseLocal),
|
||||||
|
sunset: fmt(sunsetLocal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moon phase info for a given Julian date. */
|
||||||
|
export function getMoonInfo(jdate: JDate): LunarInfo {
|
||||||
|
const phase = Paschalion.getLunarPhase(jdate);
|
||||||
|
|
||||||
|
// Map phase (0-1) to emoji (0=new, 0.5=full)
|
||||||
|
let emoji: string;
|
||||||
|
if (phase < 0.0625) emoji = '🌑'; // new
|
||||||
|
else if (phase < 0.1875) emoji = '🌒'; // waxing crescent
|
||||||
|
else if (phase < 0.3125) emoji = '🌓'; // first quarter
|
||||||
|
else if (phase < 0.4375) emoji = '🌔'; // waxing gibbous
|
||||||
|
else if (phase < 0.5625) emoji = '🌕'; // full
|
||||||
|
else if (phase < 0.6875) emoji = '🌖'; // waning gibbous
|
||||||
|
else if (phase < 0.8125) emoji = '🌗'; // last quarter
|
||||||
|
else if (phase < 0.9375) emoji = '🌘'; // waning crescent
|
||||||
|
else emoji = '🌑'; // new
|
||||||
|
|
||||||
|
// Illumination (0–100%)
|
||||||
|
const illumination = Math.round((1 - Math.cos(2 * Math.PI * phase)) / 2 * 100);
|
||||||
|
|
||||||
|
return { phase, emoji, name: '', illumination };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phase names keyed by phase bucket (0=new, 1-7 for other phases). */
|
||||||
|
export function getMoonPhaseName(phase: number, lang: string): string {
|
||||||
|
const names: Record<string, string[]> = {
|
||||||
|
en: ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
|
||||||
|
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'],
|
||||||
|
ru: ['Новолуние', 'Растущий серп', 'Первая четверть', 'Растущая луна',
|
||||||
|
'Полнолуние', 'Убывающая луна', 'Последняя четверть', 'Убывающий серп'],
|
||||||
|
cu: ['Новолу́нїе', 'Растꙋ́щїй се́рпъ', 'Пе́рваѧ че́тверть', 'Растꙋ́щаѧ лꙋна̀',
|
||||||
|
'Полнолу́нїе', 'Оу҆быва́ющаѧ лꙋна̀', 'Послѣ́днѧѧ че́тверть', 'Оу҆быва́ющїй се́рпъ'],
|
||||||
|
};
|
||||||
|
const list = names[lang] || names.en;
|
||||||
|
const index = Math.round(phase * 8) % 8;
|
||||||
|
return list[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(d: number): number { return d * Math.PI / 180; }
|
||||||
|
function radToDeg(r: number): number { return r * 180 / Math.PI; }
|
||||||
@@ -240,6 +240,10 @@ export interface Translations {
|
|||||||
yearOfWorld: string;
|
yearOfWorld: string;
|
||||||
fromYear: string;
|
fromYear: string;
|
||||||
fromAdam: string;
|
fromAdam: string;
|
||||||
|
sunrise: string;
|
||||||
|
sunset: string;
|
||||||
|
moonPhase: string;
|
||||||
|
noLocation: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -495,6 +499,11 @@ export interface Translations {
|
|||||||
offlineDone: string;
|
offlineDone: string;
|
||||||
offlineSelectLang: string;
|
offlineSelectLang: string;
|
||||||
offlineCleared: string;
|
offlineCleared: string;
|
||||||
|
locationTitle: string;
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
|
requestLocation: string;
|
||||||
|
locationNote: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
@@ -779,6 +788,10 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
yearOfWorld: 'year of the world',
|
yearOfWorld: 'year of the world',
|
||||||
fromYear: 'from Christ',
|
fromYear: 'from Christ',
|
||||||
fromAdam: 'from Adam',
|
fromAdam: 'from Adam',
|
||||||
|
sunrise: 'Sunrise',
|
||||||
|
sunset: 'Sunset',
|
||||||
|
moonPhase: 'Moon',
|
||||||
|
noLocation: 'Set location in Settings',
|
||||||
},
|
},
|
||||||
|
|
||||||
services: {
|
services: {
|
||||||
@@ -1031,6 +1044,11 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineDone: 'Done! Data cached for offline use.',
|
offlineDone: 'Done! Data cached for offline use.',
|
||||||
offlineSelectLang: 'Please select at least one language.',
|
offlineSelectLang: 'Please select at least one language.',
|
||||||
offlineCleared: 'Cache cleared.',
|
offlineCleared: 'Cache cleared.',
|
||||||
|
locationTitle: 'Location',
|
||||||
|
latitude: 'Latitude',
|
||||||
|
longitude: 'Longitude',
|
||||||
|
requestLocation: 'Request location',
|
||||||
|
locationNote: 'Used for sunrise/sunset calculation',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
@@ -1313,6 +1331,10 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
yearOfWorld: 'год от сотворения мира',
|
yearOfWorld: 'год от сотворения мира',
|
||||||
fromYear: 'от РХ',
|
fromYear: 'от РХ',
|
||||||
fromAdam: 'от Адама',
|
fromAdam: 'от Адама',
|
||||||
|
sunrise: 'Восход',
|
||||||
|
sunset: 'Закат',
|
||||||
|
moonPhase: 'Луна',
|
||||||
|
noLocation: 'Укажите местоположение в настройках',
|
||||||
},
|
},
|
||||||
|
|
||||||
services: {
|
services: {
|
||||||
@@ -1565,6 +1587,11 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
||||||
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
|
||||||
offlineCleared: 'Кэш очищен.',
|
offlineCleared: 'Кэш очищен.',
|
||||||
|
locationTitle: 'Местоположение',
|
||||||
|
latitude: 'Широта',
|
||||||
|
longitude: 'Долгота',
|
||||||
|
requestLocation: 'Запросить местоположение',
|
||||||
|
locationNote: 'Используется для расчёта восхода/заката',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
@@ -1847,6 +1874,10 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
yearOfWorld: 'лѣта отъ сотворенiя мiра',
|
yearOfWorld: 'лѣта отъ сотворенiя мiра',
|
||||||
fromYear: 'от РХ',
|
fromYear: 'от РХ',
|
||||||
fromAdam: 'от Адама',
|
fromAdam: 'от Адама',
|
||||||
|
sunrise: 'Восходъ',
|
||||||
|
sunset: 'Закатъ',
|
||||||
|
moonPhase: 'Лꙋна̀',
|
||||||
|
noLocation: 'Оу҆кажі́те мѣстоположе́нїе въ настройкахъ',
|
||||||
},
|
},
|
||||||
|
|
||||||
services: {
|
services: {
|
||||||
@@ -2099,6 +2130,11 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
||||||
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
|
||||||
offlineCleared: 'Кешъ очищенъ.',
|
offlineCleared: 'Кешъ очищенъ.',
|
||||||
|
locationTitle: 'Мѣстоположе́нїе',
|
||||||
|
latitude: 'Широта̀',
|
||||||
|
longitude: 'Долгота̀',
|
||||||
|
requestLocation: 'Запроси́ти мѣстоположе́нїе',
|
||||||
|
locationNote: 'И҆спо́льзꙋетсѧ длѧ ра́счета восхо́да / зака́та',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import type { FastingPeriod } from '../core/fasting';
|
|||||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||||
import { DataCache } from '../core/data-cache';
|
import { DataCache } from '../core/data-cache';
|
||||||
import { fontClass } from './settings-view';
|
import { fontClass } from './settings-view';
|
||||||
|
import { loadSettings } from './settings-view';
|
||||||
import { ServiceView } from './service-view';
|
import { ServiceView } from './service-view';
|
||||||
|
import { calcSolarTimes, getMoonInfo, getMoonPhaseName } from '../core/astronomy';
|
||||||
|
|
||||||
let cachedFastingPeriods: FastingPeriod[] | null = null;
|
let cachedFastingPeriods: FastingPeriod[] | null = null;
|
||||||
const lifeCache = new Map<string, LifeData | null>();
|
const lifeCache = new Map<string, LifeData | null>();
|
||||||
@@ -596,6 +598,10 @@ export class CalendarView {
|
|||||||
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="astro-panel" class="mb-4 p-2 bg-white/50 border border-gold/20 rounded-lg text-sm text-navy">
|
||||||
|
<span class="text-navy-light italic">${this.t.loading}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="services-panel" class="mb-4 border-t border-gold/20 pt-3"></div>
|
<div id="services-panel" class="mb-4 border-t border-gold/20 pt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -928,6 +934,35 @@ export class CalendarView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Astro panel: sunrise/sunset + moon phase
|
||||||
|
const astroPanel = document.getElementById('astro-panel');
|
||||||
|
if (astroPanel) {
|
||||||
|
try {
|
||||||
|
const settings = loadSettings();
|
||||||
|
const lat = settings.latitude;
|
||||||
|
const lng = settings.longitude;
|
||||||
|
if (lat !== undefined && lng !== undefined && !isNaN(lat) && !isNaN(lng)) {
|
||||||
|
const solar = calcSolarTimes(this.currentDate, lat, lng, 3);
|
||||||
|
const moon = getMoonInfo(this.currentDate);
|
||||||
|
const moonName = getMoonPhaseName(moon.phase, this.language);
|
||||||
|
astroPanel.innerHTML = `
|
||||||
|
<span class="font-bold text-navy">☀ ${this.t.calendar.sunrise}:</span>
|
||||||
|
<span class="text-navy">${solar?.sunrise ?? '—'}</span>
|
||||||
|
<span class="mx-2 text-gold/40">|</span>
|
||||||
|
<span class="font-bold text-navy">${this.t.calendar.sunset}:</span>
|
||||||
|
<span class="text-navy">${solar?.sunset ?? '—'}</span>
|
||||||
|
<span class="mx-2 text-gold/40">|</span>
|
||||||
|
<span class="font-bold text-navy">${this.t.calendar.moonPhase}:</span>
|
||||||
|
<span class="text-navy">${moon.emoji} ${moonName} (${moon.illumination}%)</span>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
astroPanel.innerHTML = `<span class="text-navy-light italic">${this.t.calendar.noLocation}</span>`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
astroPanel.innerHTML = `<span class="text-navy-light italic">${this.t.loading}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load services
|
// Load services
|
||||||
const svcPanel = document.getElementById('services-panel');
|
const svcPanel = document.getElementById('services-panel');
|
||||||
if (svcPanel) {
|
if (svcPanel) {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ interface Settings {
|
|||||||
verseNewLine: boolean;
|
verseNewLine: boolean;
|
||||||
calendarType: 'julian' | 'gregorian';
|
calendarType: 'julian' | 'gregorian';
|
||||||
serviceRole: 'priest' | 'reader' | 'auto';
|
serviceRole: 'priest' | 'reader' | 'auto';
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -44,6 +46,8 @@ const DEFAULT_SETTINGS: Settings = {
|
|||||||
verseNewLine: false,
|
verseNewLine: false,
|
||||||
calendarType: 'julian',
|
calendarType: 'julian',
|
||||||
serviceRole: 'priest',
|
serviceRole: 'priest',
|
||||||
|
latitude: 55.7558,
|
||||||
|
longitude: 37.6176,
|
||||||
};
|
};
|
||||||
|
|
||||||
function defaultBibleForLanguage(lang: string): string {
|
function defaultBibleForLanguage(lang: string): string {
|
||||||
@@ -175,6 +179,29 @@ export class SettingsView {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<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>
|
||||||
|
<input type="number" id="latitude" step="0.0001" value="${this.settings.latitude}"
|
||||||
|
class="w-full border border-gold/30 rounded p-2 bg-white text-navy text-sm">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs 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-white text-navy text-sm">
|
||||||
|
</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">
|
||||||
|
${t.settings.requestLocation}
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-navy-light">${t.settings.locationNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bible Settings -->
|
<!-- Bible Settings -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<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>
|
||||||
@@ -353,6 +380,35 @@ export class SettingsView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Location
|
||||||
|
const saveLatLng = () => {
|
||||||
|
const lat = parseFloat((document.getElementById('latitude') as HTMLInputElement)?.value);
|
||||||
|
const lng = parseFloat((document.getElementById('longitude') as HTMLInputElement)?.value);
|
||||||
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
this.settings.latitude = lat;
|
||||||
|
this.settings.longitude = lng;
|
||||||
|
saveSettings(this.settings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.getElementById('latitude')?.addEventListener('change', saveLatLng);
|
||||||
|
document.getElementById('longitude')?.addEventListener('change', saveLatLng);
|
||||||
|
document.getElementById('request-location')?.addEventListener('click', () => {
|
||||||
|
if ('geolocation' in navigator) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
this.settings.latitude = Math.round(pos.coords.latitude * 10000) / 10000;
|
||||||
|
this.settings.longitude = Math.round(pos.coords.longitude * 10000) / 10000;
|
||||||
|
saveSettings(this.settings);
|
||||||
|
(document.getElementById('latitude') as HTMLInputElement).value = String(this.settings.latitude);
|
||||||
|
(document.getElementById('longitude') as HTMLInputElement).value = String(this.settings.longitude);
|
||||||
|
},
|
||||||
|
() => alert('Could not get location. Make sure location access is allowed.')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alert('Geolocation is not available in this browser.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Offline content - auto-select ru+cu when ru is selected
|
// Offline content - auto-select ru+cu when ru is selected
|
||||||
document.querySelectorAll('.offline-lang').forEach(el => {
|
document.querySelectorAll('.offline-lang').forEach(el => {
|
||||||
el.addEventListener('change', () => {
|
el.addEventListener('change', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user