feat(web): Add geolocation button, clear location, i18n, and spacing fixes
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.
This commit is contained in:
1 parent
b55fc2333c
commit
b5aecf4304
3 files changed
+156
-9
No files matched your search
@@ -39,31 +39,59 @@
|
|||||||
<header class='app-bar'>
|
<header class='app-bar'>
|
||||||
<div class='app-bar__brand'>
|
<div class='app-bar__brand'>
|
||||||
<span class='app-bar__icon' aria-hidden='true'>☽</span>
|
<span class='app-bar__icon' aria-hidden='true'>☽</span>
|
||||||
<span class='app-bar__title'>Prayer Times</span>
|
<span class='app-bar__title'
|
||||||
|
x-text="t('appTitle')"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='location-search'>
|
<div class='location-search'>
|
||||||
<input type='search'
|
<input type='search'
|
||||||
class='location-search__input'
|
class='location-search__input'
|
||||||
placeholder='Search location...'
|
:placeholder="t('searchPlaceholder')"
|
||||||
x-model='searchQuery'
|
x-model='searchQuery'
|
||||||
@input='onSearchInput'
|
@input='onSearchInput'
|
||||||
@focus='searchOpen = searchResults.length > 0'
|
@focus='searchOpen = searchResults.length > 0'
|
||||||
@click.outside='searchOpen = false'>
|
@click.outside='searchOpen = false'>
|
||||||
|
|
||||||
|
<template x-if='!selectedLocation'>
|
||||||
|
<button type='button'
|
||||||
|
class='location-search__locate'
|
||||||
|
:class="{'location-search__locate--busy': locating}"
|
||||||
|
:disabled='locating'
|
||||||
|
:title="locateError ? t(locateError) : t('useCurrentLocation')"
|
||||||
|
:aria-label="t('useCurrentLocation')"
|
||||||
|
@click='useCurrentLocation'>
|
||||||
|
<svg xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='1em'
|
||||||
|
height='1em'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
aria-hidden='true'>
|
||||||
|
<path d='M0 0h24v24H0z'
|
||||||
|
fill='none' />
|
||||||
|
<path fill='currentColor'
|
||||||
|
d='M12 8.25a3.75 3.75 0 1 0 0 7.5a3.75 3.75 0 0 0 0-7.5' />
|
||||||
|
<path fill='currentColor'
|
||||||
|
fill-rule='evenodd'
|
||||||
|
d='M12 1.25a.75.75 0 0 1 .75.75v1.282a8.75 8.75 0 0 1 7.968 7.968H22a.75.75 0 0 1 0 1.5h-1.282a8.75 8.75 0 0 1-7.968 7.968V22a.75.75 0 0 1-1.5 0v-1.282a8.75 8.75 0 0 1-7.968-7.968H2a.75.75 0 0 1 0-1.5h1.282a8.75 8.75 0 0 1 7.968-7.968V2a.75.75 0 0 1 .75-.75M4.75 12a7.25 7.25 0 1 0 14.5 0a7.25 7.25 0 0 0-14.5 0'
|
||||||
|
clip-rule='evenodd' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template x-if='searchOpen'>
|
<template x-if='searchOpen'>
|
||||||
<div class='location-search__dropdown'>
|
<div class='location-search__dropdown'>
|
||||||
<template x-if='searchLoading'>
|
<template x-if='searchLoading'>
|
||||||
<div class='location-search__state'>Searching...</div>
|
<div class='location-search__state'
|
||||||
|
x-text="t('searching')"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if='!searchLoading && searchError'>
|
<template x-if='!searchLoading && searchError'>
|
||||||
<div class='location-search__state'
|
<div class='location-search__state'
|
||||||
x-text='searchError'></div>
|
x-text='t(searchError)'></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
|
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
|
||||||
<div class='location-search__state'>No results</div>
|
<div class='location-search__state'
|
||||||
|
x-text="t('noResults')"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
|
<template x-if='!searchLoading && !searchError && searchResults.length > 0'>
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ const app = () => ({
|
|||||||
userMinutes: 0,
|
userMinutes: 0,
|
||||||
now: new Date(),
|
now: new Date(),
|
||||||
debug: hasDebugHashFlag(),
|
debug: hasDebugHashFlag(),
|
||||||
|
locating: false,
|
||||||
|
locateError: "",
|
||||||
|
|
||||||
|
t(key) {
|
||||||
|
const lang = this.preferredSalathLangs[0];
|
||||||
|
return UI_STRINGS[key]?.[lang] ?? UI_STRINGS[key]?.en ?? key;
|
||||||
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const urlCoords = getURLCoords();
|
const urlCoords = getURLCoords();
|
||||||
@@ -45,6 +52,19 @@ const app = () => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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() {
|
onSearchInput() {
|
||||||
this.searchError = "";
|
this.searchError = "";
|
||||||
if (this.searchDebounceTimer) {
|
if (this.searchDebounceTimer) {
|
||||||
@@ -55,6 +75,9 @@ const app = () => ({
|
|||||||
if (query === "") {
|
if (query === "") {
|
||||||
this.searchResults = [];
|
this.searchResults = [];
|
||||||
this.searchOpen = false;
|
this.searchOpen = false;
|
||||||
|
if (this.selectedLocation) {
|
||||||
|
this.clearLocation();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +94,7 @@ const app = () => ({
|
|||||||
this.searchResults = response.locations ?? [];
|
this.searchResults = response.locations ?? [];
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
this.searchResults = [];
|
this.searchResults = [];
|
||||||
this.searchError = "Failed to search locations.";
|
this.searchError = "searchFailed";
|
||||||
} finally {
|
} finally {
|
||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
}
|
}
|
||||||
@@ -97,6 +120,16 @@ const app = () => ({
|
|||||||
await this.refreshPrayerTimes();
|
await this.refreshPrayerTimes();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearLocation() {
|
||||||
|
this.selectedLocation = null;
|
||||||
|
this.searchQuery = "";
|
||||||
|
this.searchResults = [];
|
||||||
|
this.searchOpen = false;
|
||||||
|
this.futureTimes = [];
|
||||||
|
this.lastUpdated = null;
|
||||||
|
clearURLCoords();
|
||||||
|
},
|
||||||
|
|
||||||
formatLocationLabel(location) {
|
formatLocationLabel(location) {
|
||||||
if (!location) {
|
if (!location) {
|
||||||
return "";
|
return "";
|
||||||
@@ -173,6 +206,51 @@ const app = () => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
class PrayerTimes {
|
||||||
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||||
static translations = {
|
static translations = {
|
||||||
@@ -367,3 +445,12 @@ function setURLCoords(latitude, longitude) {
|
|||||||
url.searchParams.delete("lon");
|
url.searchParams.delete("lon");
|
||||||
window.history.replaceState({}, "", url.toString());
|
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());
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding: 0.875rem 1.25rem;
|
padding: clamp(1rem, 4vh, 2rem) 1.25rem 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-bar__brand {
|
.app-bar__brand {
|
||||||
@@ -72,11 +72,16 @@ body {
|
|||||||
|
|
||||||
.location-search {
|
.location-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 24rem;
|
max-width: 24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-search__input {
|
.location-search__input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
@@ -86,6 +91,33 @@ body {
|
|||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-search__locate {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__locate:hover {
|
||||||
|
background: #f4f1e4;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-search__locate:disabled,
|
||||||
|
.location-search__locate--busy {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.location-search__input:focus {
|
.location-search__input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
@@ -175,7 +207,7 @@ p + p {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
gap: clamp(0.5rem, 2vh, 1.25rem);
|
gap: clamp(0.5rem, 2vh, 1.25rem);
|
||||||
padding: 0.5rem 1rem;
|
padding: clamp(0.5rem, 2vh, 1rem) 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +314,7 @@ tr.current .cell-names {
|
|||||||
|
|
||||||
.app-footer {
|
.app-footer {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 0.25rem 1rem 0.75rem;
|
padding: clamp(0.5rem, 2vh, 1rem) 1rem clamp(1rem, 4vh, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clock {
|
.clock {
|
||||||
|
|||||||
Reference in new issue
Block a user