Add a "use my current location" button next to the search box (shown only when no location is selected), make clearing the search box reset selected location, prayer times, and URL coords, rebalance vertical spacing so the clock isn't flush against the viewport edge, and translate all UI copy based on browser language.
457 lines
13 KiB
JavaScript
457 lines
13 KiB
JavaScript
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) {
|
||
try {
|
||
const coords = await getUserLocation();
|
||
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
|
||
} catch (_error) {
|
||
// Ignore geolocation errors and rely on manual search.
|
||
}
|
||
}
|
||
},
|
||
|
||
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)
|
||
);
|
||
});
|
||
}
|
||
|
||
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());
|
||
}
|