feat: Add location search
This commit is contained in:
@@ -35,8 +35,47 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<input type='text'
|
||||
x-model='locationId'>
|
||||
<div class='location-search'>
|
||||
<input type='search'
|
||||
class='location-search__input'
|
||||
placeholder='Search location...'
|
||||
x-model='searchQuery'
|
||||
@input='onSearchInput'
|
||||
@focus='searchOpen = searchResults.length > 0'
|
||||
@click.outside='searchOpen = false'>
|
||||
|
||||
<template x-if='searchOpen'>
|
||||
<div class='location-search__dropdown'>
|
||||
<template x-if='searchLoading'>
|
||||
<div class='location-search__state'>Searching...</div>
|
||||
</template>
|
||||
|
||||
<template x-if='!searchLoading && searchError'>
|
||||
<div class='location-search__state'
|
||||
x-text='searchError'></div>
|
||||
</template>
|
||||
|
||||
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
|
||||
<div class='location-search__state'>No results</div>
|
||||
</template>
|
||||
|
||||
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
|
||||
<ul class='location-search__results'>
|
||||
<template x-for='location in searchResults'
|
||||
:key='location.id'>
|
||||
<li>
|
||||
<button type='button'
|
||||
class='location-search__item'
|
||||
@click='selectLocation(location)'>
|
||||
<span x-text='formatLocationLabel(location)'></span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template x-if='todayTimes'>
|
||||
<div class='current-salath text--center'>
|
||||
@@ -95,4 +134,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+132
-96
@@ -1,29 +1,115 @@
|
||||
const app = () => ({
|
||||
futureTimes: [],
|
||||
|
||||
locationId: Alpine.$persist('11002'),
|
||||
selectedLocation: Alpine.$persist(null),
|
||||
lastUpdated: Alpine.$persist(null),
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
searchOpen: false,
|
||||
searchLoading: false,
|
||||
searchError: "",
|
||||
searchDebounceTimer: null,
|
||||
userMinutes: 0,
|
||||
now: new Date(),
|
||||
debug: location.hash === '#debug',
|
||||
geolocation: null,
|
||||
debug: location.hash === "#debug",
|
||||
|
||||
async init() {
|
||||
await this.refreshIfStale();
|
||||
if (this.selectedLocation) {
|
||||
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
|
||||
await this.refreshPrayerTimes();
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
this.now = new Date();
|
||||
}, 500);
|
||||
|
||||
getUserLocation()
|
||||
.then(loc => {
|
||||
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude};
|
||||
this.refreshIfStale();
|
||||
})
|
||||
.catch(() => this.geolocation = null)
|
||||
try {
|
||||
const coords = await getUserLocation();
|
||||
await this.selectNearestLocation(coords.latitude, coords.longitude);
|
||||
} catch (_error) {
|
||||
// Ignore geolocation errors and rely on manual search.
|
||||
}
|
||||
},
|
||||
|
||||
onHash() {
|
||||
this.debug = location.hash === '#debug'
|
||||
this.debug = location.hash === "#debug";
|
||||
},
|
||||
|
||||
onSearchInput() {
|
||||
this.searchError = "";
|
||||
if (this.searchDebounceTimer) {
|
||||
clearTimeout(this.searchDebounceTimer);
|
||||
}
|
||||
|
||||
const query = this.searchQuery.trim();
|
||||
if (query === "") {
|
||||
this.searchResults = [];
|
||||
this.searchOpen = false;
|
||||
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 = "Failed to search locations.";
|
||||
} finally {
|
||||
this.searchLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async selectNearestLocation(latitude, longitude) {
|
||||
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);
|
||||
},
|
||||
|
||||
async selectLocation(location) {
|
||||
this.selectedLocation = location;
|
||||
this.searchQuery = this.formatLocationLabel(location);
|
||||
this.searchOpen = false;
|
||||
this.searchResults = [];
|
||||
await this.refreshPrayerTimes();
|
||||
},
|
||||
|
||||
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() {
|
||||
@@ -40,90 +126,66 @@ const app = () => ({
|
||||
return formatTime(this.userNow);
|
||||
},
|
||||
|
||||
async refreshIfStale() {
|
||||
const updatedAt = new Date(this.lastUpdated);
|
||||
const now = new Date();
|
||||
|
||||
const elapsedSeconds = (now - updatedAt) / 1000;
|
||||
|
||||
if (this.geolocation !== null) {
|
||||
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?latitude=${this.geolocation.latitude}&longitude=${this.geolocation.longitude}`);
|
||||
this.futureTimes = response.prayertimes ?? [];
|
||||
} else {
|
||||
const response = await fetchJSON(`/api/v1/diyanet/prayertimes?location_id=${this.locationId}`);
|
||||
this.futureTimes = response.prayertimes ?? [];
|
||||
}
|
||||
this.lastUpdated = now.toISOString();
|
||||
},
|
||||
|
||||
get todayTimes() {
|
||||
if (this.futureTimes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
||||
},
|
||||
|
||||
translate(key, lang) {
|
||||
return translations[key][lang] ?? key
|
||||
}
|
||||
});
|
||||
|
||||
class PrayerTimes {
|
||||
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'];
|
||||
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||
static translations = {
|
||||
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'},
|
||||
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'},
|
||||
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'},
|
||||
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'},
|
||||
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'},
|
||||
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'},
|
||||
}
|
||||
fajr: {tr: "İmsak", de: "Fruehgebet", ar: "صلاة الفجر"},
|
||||
sunrise: {tr: "Günes", de: "Sonnenaufgang", ar: "الشروق"},
|
||||
dhuhr: {tr: "Öğle", de: "Mittagsgebet", ar: "صلاة الظهر"},
|
||||
asr: {tr: "İkindi", de: "Nachmittagsgebet", ar: "صلاة العصر"},
|
||||
maghrib: {tr: "Aksam", de: "Abendgebet", ar: "صلاة المغرب"},
|
||||
isha: {tr: "Yatsı", de: "Nachtgebet", ar: "صلاة العشاء"}
|
||||
};
|
||||
|
||||
constructor({date, ...rest}, clock = () => new Date()) {
|
||||
this.date = date;
|
||||
this.clock = clock
|
||||
this.salathTimes = rest
|
||||
this.clock = clock;
|
||||
this.salathTimes = rest;
|
||||
}
|
||||
|
||||
get times() {
|
||||
const now = this.clock()
|
||||
return PrayerTimes.salaths.map(k => {
|
||||
// "2023-03-05T00:00:00Z"
|
||||
const startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, ''));
|
||||
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] ?? '??',
|
||||
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
||||
startsAt,
|
||||
timeLocal: this.salathTimes[k],
|
||||
get untilSeconds() {
|
||||
let untilSeconds = (startsAt - now) / 1000;
|
||||
const untilSeconds = (startsAt - now) / 1000;
|
||||
return now > startsAt ? 0 : untilSeconds;
|
||||
},
|
||||
get untilHuman() {
|
||||
return formatDuration(this.untilSeconds)
|
||||
},
|
||||
}
|
||||
})
|
||||
return formatDuration(this.untilSeconds);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
get currentSalath() {
|
||||
let current = this.times.filter(it => it.untilSeconds === 0).at(-1);
|
||||
let current = this.times.filter((it) => it.untilSeconds === 0).at(-1);
|
||||
if (current === undefined) {
|
||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
||||
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
|
||||
return current;
|
||||
}
|
||||
|
||||
get nextSalath() {
|
||||
let next = this.times
|
||||
.filter(it => it.untilSeconds > 0)[0]
|
||||
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
||||
if (next === undefined) {
|
||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
||||
const nextDay = new Date(this.date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
@@ -131,24 +193,16 @@ class PrayerTimes {
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
}
|
||||
...next
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} seconds
|
||||
* @return {string}
|
||||
* */
|
||||
function formatDuration(seconds) {
|
||||
const d = new Date(0, 0, 0, 0, 0, seconds);
|
||||
return formatTime(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} then
|
||||
* @return {string}
|
||||
* */
|
||||
function formatTime(then) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
hour: "numeric",
|
||||
@@ -157,10 +211,6 @@ function formatTime(then) {
|
||||
}).format(then);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} then
|
||||
* @return {string}
|
||||
* */
|
||||
function formatDate(then) {
|
||||
return new Intl.DateTimeFormat(navigator.language, {
|
||||
year: "numeric",
|
||||
@@ -169,42 +219,28 @@ function formatDate(then) {
|
||||
}).format(then);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} then
|
||||
* @return {string}
|
||||
* */
|
||||
function formatDateHijri(then) {
|
||||
return new Intl.DateTimeFormat("en-u-ca-islamic-umalqura-nu-latn", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(then);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {RequestInit} req
|
||||
* */
|
||||
async function fetchJSON(url, req = {}) {
|
||||
const res = await fetch(url, {
|
||||
...req,
|
||||
})
|
||||
return res.json()
|
||||
...req
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<GeolocationCoordinates>}
|
||||
* */
|
||||
function getUserLocation() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject("Geolocation is not supported by this browser.");
|
||||
reject(new Error("Geolocation is not supported by this browser."));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position.coords),
|
||||
(error) => reject(error.message)
|
||||
(error) => reject(error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,60 @@ body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.location-search {
|
||||
position: relative;
|
||||
max-width: 38rem;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.location-search__input {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.location-search__dropdown {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
top: calc(100% + 0.25rem);
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.75rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.location-search__results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.location-search__item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-search__item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.location-search__state {
|
||||
padding: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -39,6 +93,21 @@ p + p {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.salath-table {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.salath-table {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
@@ -85,4 +154,4 @@ td {
|
||||
.current-salath {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user