Add a minimal cfgeo Cloudflare Worker that returns coarse location from Cloudflare's edge geolocation data. Use it as a fast default location on load, preferring browser geolocation or an explicit search/URL selection whenever those are available.
497 lines
14 KiB
JavaScript
497 lines
14 KiB
JavaScript
const CFGEO_ENDPOINT = "https://cfgeo.abdusco.workers.dev/";
|
||
const CFGEO_TIMEOUT_MS = 2000;
|
||
|
||
const app = () => ({
|
||
futureTimes: [],
|
||
selectedLocation: Alpine.$persist(null),
|
||
lastUpdated: Alpine.$persist(null),
|
||
searchQuery: "",
|
||
searchResults: [],
|
||
searchOpen: false,
|
||
searchLoading: false,
|
||
searchError: "",
|
||
searchDebounceTimer: null,
|
||
supportedSalathLangs: ["tr", "de", "en", "ar"],
|
||
userMinutes: 0,
|
||
now: new Date(),
|
||
debug: hasDebugHashFlag(),
|
||
locating: false,
|
||
locateError: "",
|
||
|
||
t(key) {
|
||
const lang = this.preferredSalathLangs[0];
|
||
return UI_STRINGS[key]?.[lang] ?? UI_STRINGS[key]?.en ?? key;
|
||
},
|
||
|
||
async init() {
|
||
const urlCoords = getURLCoords();
|
||
if (urlCoords) {
|
||
await this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
||
} else if (this.selectedLocation) {
|
||
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
|
||
await this.refreshPrayerTimes();
|
||
}
|
||
|
||
setInterval(() => {
|
||
this.now = new Date();
|
||
}, 500);
|
||
|
||
if (!urlCoords) {
|
||
await this.resolveDefaultLocation();
|
||
}
|
||
},
|
||
|
||
async resolveDefaultLocation() {
|
||
let browserResolved = false;
|
||
const geoPromise = getUserLocation()
|
||
.then((coords) => {
|
||
browserResolved = true;
|
||
return coords;
|
||
})
|
||
.catch(() => null);
|
||
|
||
if (!this.selectedLocation) {
|
||
const cfCoords = await withTimeout(getCFGeoLocation(), CFGEO_TIMEOUT_MS);
|
||
if (cfCoords && !browserResolved) {
|
||
await this.selectNearestLocation(cfCoords.latitude, cfCoords.longitude, {updateURL: false});
|
||
}
|
||
}
|
||
|
||
const geoCoords = await geoPromise;
|
||
if (geoCoords) {
|
||
await this.selectNearestLocation(geoCoords.latitude, geoCoords.longitude, {updateURL: true});
|
||
}
|
||
},
|
||
|
||
onHash() {
|
||
this.debug = hasDebugHashFlag();
|
||
|
||
const urlCoords = getURLCoords();
|
||
if (urlCoords) {
|
||
this.selectNearestLocation(urlCoords.latitude, urlCoords.longitude, {updateURL: false});
|
||
}
|
||
},
|
||
|
||
async useCurrentLocation() {
|
||
this.locateError = "";
|
||
this.locating = true;
|
||
try {
|
||
const coords = await getUserLocation();
|
||
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
|
||
} catch (_error) {
|
||
this.locateError = "locateFailed";
|
||
} finally {
|
||
this.locating = false;
|
||
}
|
||
},
|
||
|
||
onSearchInput() {
|
||
this.searchError = "";
|
||
if (this.searchDebounceTimer) {
|
||
clearTimeout(this.searchDebounceTimer);
|
||
}
|
||
|
||
const query = this.searchQuery.trim();
|
||
if (query === "") {
|
||
this.searchResults = [];
|
||
this.searchOpen = false;
|
||
if (this.selectedLocation) {
|
||
this.clearLocation();
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.searchDebounceTimer = setTimeout(() => {
|
||
this.searchLocations(query);
|
||
}, 250);
|
||
},
|
||
|
||
async searchLocations(query) {
|
||
this.searchLoading = true;
|
||
this.searchOpen = true;
|
||
try {
|
||
const response = await fetchJSON(`/api/v1/diyanet/location?query=${encodeURIComponent(query)}`);
|
||
this.searchResults = response.locations ?? [];
|
||
} catch (_error) {
|
||
this.searchResults = [];
|
||
this.searchError = "searchFailed";
|
||
} finally {
|
||
this.searchLoading = false;
|
||
}
|
||
},
|
||
|
||
async selectNearestLocation(latitude, longitude, {updateURL = false} = {}) {
|
||
const response = await fetchJSON(`/api/v1/diyanet/location?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
||
const first = (response.locations ?? [])[0];
|
||
if (!first) {
|
||
return;
|
||
}
|
||
await this.selectLocation(first, {updateURL});
|
||
},
|
||
|
||
async selectLocation(location, {updateURL = true} = {}) {
|
||
this.selectedLocation = location;
|
||
this.searchQuery = this.formatLocationLabel(location);
|
||
this.searchOpen = false;
|
||
this.searchResults = [];
|
||
if (updateURL) {
|
||
setURLCoords(location.latitude, location.longitude);
|
||
}
|
||
await this.refreshPrayerTimes();
|
||
},
|
||
|
||
clearLocation() {
|
||
this.selectedLocation = null;
|
||
this.searchQuery = "";
|
||
this.searchResults = [];
|
||
this.searchOpen = false;
|
||
this.futureTimes = [];
|
||
this.lastUpdated = null;
|
||
clearURLCoords();
|
||
},
|
||
|
||
formatLocationLabel(location) {
|
||
if (!location) {
|
||
return "";
|
||
}
|
||
const country = (location.country_code || "").trim();
|
||
const name = (location.name || "").trim();
|
||
const asciiName = (location.ascii_name || "").trim();
|
||
if (name && asciiName && name !== asciiName) {
|
||
return `${name} (${asciiName})${country ? ` - ${country}` : ""}`;
|
||
}
|
||
if (name) {
|
||
return `${name}${country ? ` - ${country}` : ""}`;
|
||
}
|
||
return `${asciiName}${country ? ` - ${country}` : ""}`;
|
||
},
|
||
|
||
async refreshPrayerTimes() {
|
||
if (!this.selectedLocation) {
|
||
this.futureTimes = [];
|
||
return;
|
||
}
|
||
|
||
const latitude = this.selectedLocation.latitude;
|
||
const longitude = this.selectedLocation.longitude;
|
||
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${encodeURIComponent(latitude)}&longitude=${encodeURIComponent(longitude)}`);
|
||
this.futureTimes = response.prayertimes ?? [];
|
||
this.lastUpdated = new Date().toISOString();
|
||
},
|
||
|
||
get userNow() {
|
||
if (!this.debug) {
|
||
return this.now;
|
||
}
|
||
|
||
const d = new Date();
|
||
d.setHours(0, this.userMinutes, 0, 0);
|
||
return d;
|
||
},
|
||
|
||
get userTime() {
|
||
return formatTime(this.userNow);
|
||
},
|
||
|
||
get todayTimes() {
|
||
if (this.futureTimes.length === 0) {
|
||
return null;
|
||
}
|
||
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
||
},
|
||
|
||
get preferredSalathLangs() {
|
||
const browserLanguages = (navigator.languages && navigator.languages.length > 0)
|
||
? navigator.languages
|
||
: [navigator.language];
|
||
|
||
const preferred = browserLanguages
|
||
.filter(Boolean)
|
||
.map((tag) => normalizeLanguageTag(tag))
|
||
.filter((lang) => this.supportedSalathLangs.includes(lang));
|
||
|
||
return Array.from(new Set([...preferred, ...this.supportedSalathLangs]));
|
||
},
|
||
|
||
salathNameDisplay(salath) {
|
||
const names = this.preferredSalathLangs.map((lang) => ({
|
||
lang,
|
||
text: PrayerTimes.translations[salath]?.[lang] ?? salath
|
||
}));
|
||
|
||
return {
|
||
primary: names[0],
|
||
secondary: names.slice(1)
|
||
};
|
||
}
|
||
});
|
||
|
||
const UI_STRINGS = {
|
||
appTitle: {
|
||
tr: "Namaz Vakitleri",
|
||
en: "Prayer Times",
|
||
de: "Gebetszeiten",
|
||
ar: "مواقيت الصلاة"
|
||
},
|
||
searchPlaceholder: {
|
||
tr: "Konum ara...",
|
||
en: "Search location...",
|
||
de: "Ort suchen...",
|
||
ar: "ابحث عن موقع..."
|
||
},
|
||
useCurrentLocation: {
|
||
tr: "Mevcut konumumu kullan",
|
||
en: "Use my current location",
|
||
de: "Meinen aktuellen Standort verwenden",
|
||
ar: "استخدم موقعي الحالي"
|
||
},
|
||
searching: {
|
||
tr: "Aranıyor...",
|
||
en: "Searching...",
|
||
de: "Suche läuft...",
|
||
ar: "جارٍ البحث..."
|
||
},
|
||
noResults: {
|
||
tr: "Sonuç bulunamadı",
|
||
en: "No results",
|
||
de: "Keine Ergebnisse",
|
||
ar: "لا توجد نتائج"
|
||
},
|
||
locateFailed: {
|
||
tr: "Konumunuz alınamadı.",
|
||
en: "Could not get your location.",
|
||
de: "Standort konnte nicht ermittelt werden.",
|
||
ar: "تعذر تحديد موقعك."
|
||
},
|
||
searchFailed: {
|
||
tr: "Konum araması başarısız oldu.",
|
||
en: "Failed to search locations.",
|
||
de: "Ortssuche fehlgeschlagen.",
|
||
ar: "فشل البحث عن الموقع."
|
||
}
|
||
};
|
||
|
||
class PrayerTimes {
|
||
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||
static translations = {
|
||
fajr: {
|
||
tr: "İmsak",
|
||
en: "Fajr",
|
||
de: "Fruehgebet",
|
||
ar: "صلاة الفجر"
|
||
},
|
||
sunrise: {
|
||
tr: "Güneş",
|
||
en: "Sunrise",
|
||
de: "Sonnenaufgang",
|
||
ar: "الشروق"
|
||
},
|
||
dhuhr: {
|
||
tr: "Öğle",
|
||
en: "Dhuhr",
|
||
de: "Mittagsgebet",
|
||
ar: "صلاة الظهر"
|
||
},
|
||
asr: {
|
||
tr: "İkindi",
|
||
en: "Asr",
|
||
de: "Nachmittagsgebet",
|
||
ar: "صلاة العصر"
|
||
},
|
||
maghrib: {
|
||
tr: "Akşam",
|
||
en: "Maghrib",
|
||
de: "Abendgebet",
|
||
ar: "صلاة المغرب"
|
||
},
|
||
isha: {
|
||
tr: "Yatsı",
|
||
en: "Isha",
|
||
de: "Nachtgebet",
|
||
ar: "صلاة العشاء"
|
||
}
|
||
};
|
||
|
||
constructor({date, ...rest}, clock = () => new Date()) {
|
||
this.date = date;
|
||
this.clock = clock;
|
||
this.salathTimes = rest;
|
||
}
|
||
|
||
get times() {
|
||
const now = this.clock();
|
||
return PrayerTimes.salaths.map((k) => {
|
||
const startsAt = new Date(this.date.replace("T00:00", `T${this.salathTimes[k]}`).replace(/Z$/, ""));
|
||
|
||
return {
|
||
salath: k,
|
||
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
||
startsAt,
|
||
timeLocal: this.salathTimes[k],
|
||
get untilSeconds() {
|
||
const untilSeconds = (startsAt - now) / 1000;
|
||
return now > startsAt ? 0 : untilSeconds;
|
||
},
|
||
get untilHuman() {
|
||
return formatDuration(this.untilSeconds);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
get currentSalath() {
|
||
let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
|
||
if (current === undefined) {
|
||
const prevDay = new Date(this.date);
|
||
prevDay.setDate(prevDay.getDate() - 1);
|
||
|
||
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
||
}
|
||
return current;
|
||
}
|
||
|
||
get nextSalath() {
|
||
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
||
if (next === undefined) {
|
||
const nextDay = new Date(this.date);
|
||
nextDay.setDate(nextDay.getDate() + 1);
|
||
|
||
next = new PrayerTimes({date: nextDay.toISOString(), ...this.salathTimes}, this.clock).times[0];
|
||
}
|
||
|
||
return {
|
||
...next
|
||
};
|
||
}
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
const totalSeconds = Math.max(0, Math.floor(seconds));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
const secs = totalSeconds % 60;
|
||
|
||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||
}
|
||
|
||
function formatTime(then) {
|
||
return new Intl.DateTimeFormat(navigator.language, {
|
||
hour: "numeric",
|
||
minute: "numeric",
|
||
second: "numeric"
|
||
}).format(then);
|
||
}
|
||
|
||
function formatDate(then) {
|
||
return new Intl.DateTimeFormat(navigator.language, {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit"
|
||
}).format(then);
|
||
}
|
||
|
||
async function fetchJSON(url, req = {}) {
|
||
const res = await fetch(url, {
|
||
...req
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`request failed with status ${res.status}`);
|
||
}
|
||
|
||
return res.json();
|
||
}
|
||
|
||
function getUserLocation() {
|
||
return new Promise((resolve, reject) => {
|
||
if (!navigator.geolocation) {
|
||
reject(new Error("Geolocation is not supported by this browser."));
|
||
return;
|
||
}
|
||
|
||
navigator.geolocation.getCurrentPosition(
|
||
(position) => resolve(position.coords),
|
||
(error) => reject(error)
|
||
);
|
||
});
|
||
}
|
||
|
||
async function getCFGeoLocation() {
|
||
if (!CFGEO_ENDPOINT) {
|
||
return null;
|
||
}
|
||
|
||
const data = await fetchJSON(CFGEO_ENDPOINT);
|
||
if (typeof data.latitude !== "number" || typeof data.longitude !== "number") {
|
||
return null;
|
||
}
|
||
|
||
return {latitude: data.latitude, longitude: data.longitude};
|
||
}
|
||
|
||
function withTimeout(promise, ms) {
|
||
return Promise.race([
|
||
promise,
|
||
new Promise((resolve) => setTimeout(() => resolve(null), ms))
|
||
]).catch(() => null);
|
||
}
|
||
|
||
function normalizeLanguageTag(tag) {
|
||
return String(tag).toLowerCase().split("-")[0];
|
||
}
|
||
|
||
function parseCoordinate(value) {
|
||
const parsed = Number.parseFloat(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
function parseCoordsFromSearchParams(params) {
|
||
const latitude = parseCoordinate(params.get("latitude") ?? params.get("lat"));
|
||
const longitude = parseCoordinate(params.get("longitude") ?? params.get("lon"));
|
||
if (latitude === null || longitude === null) {
|
||
return null;
|
||
}
|
||
|
||
return {latitude, longitude};
|
||
}
|
||
|
||
function getHashSearchParams() {
|
||
const hash = location.hash.startsWith("#") ? location.hash.slice(1) : location.hash;
|
||
if (hash === "") {
|
||
return new URLSearchParams();
|
||
}
|
||
return new URLSearchParams(hash);
|
||
}
|
||
|
||
function hasDebugHashFlag() {
|
||
const params = getHashSearchParams();
|
||
return params.has("debug") || location.hash === "#debug";
|
||
}
|
||
|
||
function getURLCoords() {
|
||
const queryCoords = parseCoordsFromSearchParams(new URLSearchParams(location.search));
|
||
if (queryCoords) {
|
||
return queryCoords;
|
||
}
|
||
|
||
return parseCoordsFromSearchParams(getHashSearchParams());
|
||
}
|
||
|
||
function setURLCoords(latitude, longitude) {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set("latitude", String(latitude));
|
||
url.searchParams.set("longitude", String(longitude));
|
||
url.searchParams.delete("lat");
|
||
url.searchParams.delete("lon");
|
||
window.history.replaceState({}, "", url.toString());
|
||
}
|
||
|
||
function clearURLCoords() {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("latitude");
|
||
url.searchParams.delete("longitude");
|
||
url.searchParams.delete("lat");
|
||
url.searchParams.delete("lon");
|
||
window.history.replaceState({}, "", url.toString());
|
||
}
|