feat: Add location search
This commit is contained in:
+30
-4
@@ -36,6 +36,7 @@ type PrayerProvider interface {
|
|||||||
|
|
||||||
type LocationProvider interface {
|
type LocationProvider interface {
|
||||||
SearchLocations(ctx context.Context, query string) ([]prayer.Location, error)
|
SearchLocations(ctx context.Context, query string) ([]prayer.Location, error)
|
||||||
|
SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(services Services) *fiber.App {
|
func New(services Services) *fiber.App {
|
||||||
@@ -117,18 +118,43 @@ func New(services Services) *fiber.App {
|
|||||||
|
|
||||||
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
|
app.Get("/api/v1/diyanet/location", func(ctx fiber.Ctx) error {
|
||||||
var query struct {
|
var query struct {
|
||||||
Text string `query:"query"`
|
Text string `query:"query"`
|
||||||
|
Latitude string `query:"latitude"`
|
||||||
|
Longitude string `query:"longitude"`
|
||||||
}
|
}
|
||||||
if err := ctx.Bind().Query(&query); err != nil {
|
if err := ctx.Bind().Query(&query); err != nil {
|
||||||
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
return fmt.Errorf("failed to bind location query parameters: %w", errors.Join(fiber.ErrBadRequest, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
query.Text = strings.TrimSpace(query.Text)
|
query.Text = strings.TrimSpace(query.Text)
|
||||||
if query.Text == "" {
|
query.Latitude = strings.TrimSpace(query.Latitude)
|
||||||
|
query.Longitude = strings.TrimSpace(query.Longitude)
|
||||||
|
|
||||||
|
var (
|
||||||
|
locations []prayer.Location
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case query.Text != "" && query.Latitude == "" && query.Longitude == "":
|
||||||
|
locations, err = services.LocationProvider.SearchLocations(ctx.Context(), query.Text)
|
||||||
|
case query.Text == "" && query.Latitude != "" && query.Longitude != "":
|
||||||
|
lat, latErr := strconv.ParseFloat(query.Latitude, 64)
|
||||||
|
if latErr != nil {
|
||||||
|
return fmt.Errorf("failed to parse latitude query parameter: %w", errors.Join(fiber.ErrBadRequest, latErr))
|
||||||
|
}
|
||||||
|
lng, lngErr := strconv.ParseFloat(query.Longitude, 64)
|
||||||
|
if lngErr != nil {
|
||||||
|
return fmt.Errorf("failed to parse longitude query parameter: %w", errors.Join(fiber.ErrBadRequest, lngErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
locations, err = services.LocationProvider.SearchLocationsByCoords(ctx.Context(), prayer.Coordinates{
|
||||||
|
Latitude: lat,
|
||||||
|
Longitude: lng,
|
||||||
|
})
|
||||||
|
default:
|
||||||
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
|
return fmt.Errorf("failed to validate location query parameters: %w", fiber.ErrBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
locations, err := services.LocationProvider.SearchLocations(ctx.Context(), query.Text)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to search locations: %w", err)
|
return fmt.Errorf("failed to search locations: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-1
@@ -35,7 +35,11 @@ func Open(path string) (Provider, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p Provider) Close() error {
|
func (p Provider) Close() error {
|
||||||
return p.db.Db.(*sql.DB).Close()
|
if err := p.db.Db.(*sql.DB).Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type locationRow struct {
|
type locationRow struct {
|
||||||
@@ -46,6 +50,7 @@ type locationRow struct {
|
|||||||
CountryCode string `db:"country_code"`
|
CountryCode string `db:"country_code"`
|
||||||
Latitude float64 `db:"latitude"`
|
Latitude float64 `db:"latitude"`
|
||||||
Longitude float64 `db:"longitude"`
|
Longitude float64 `db:"longitude"`
|
||||||
|
DistanceSq float64 `db:"distance_sq"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) {
|
func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.Location, error) {
|
||||||
@@ -66,6 +71,7 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
|
|||||||
goqu.I("country_code"),
|
goqu.I("country_code"),
|
||||||
goqu.I("latitude"),
|
goqu.I("latitude"),
|
||||||
goqu.I("longitude"),
|
goqu.I("longitude"),
|
||||||
|
goqu.V(0).As("distance_sq"),
|
||||||
).
|
).
|
||||||
Where(
|
Where(
|
||||||
goqu.Or(
|
goqu.Or(
|
||||||
@@ -97,3 +103,49 @@ func (p Provider) SearchLocations(ctx context.Context, query string) ([]prayer.L
|
|||||||
|
|
||||||
return locations, nil
|
return locations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p Provider) SearchLocationsByCoords(ctx context.Context, coords prayer.Coordinates) ([]prayer.Location, error) {
|
||||||
|
q := p.db.
|
||||||
|
From("cities").
|
||||||
|
Select(
|
||||||
|
goqu.I("geoname_id"),
|
||||||
|
goqu.I("name"),
|
||||||
|
goqu.I("ascii_name"),
|
||||||
|
goqu.COALESCE(goqu.I("alternate_names"), "").As("alternate_names"),
|
||||||
|
goqu.I("country_code"),
|
||||||
|
goqu.I("latitude"),
|
||||||
|
goqu.I("longitude"),
|
||||||
|
goqu.L(
|
||||||
|
"((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?))",
|
||||||
|
coords.Latitude,
|
||||||
|
coords.Latitude,
|
||||||
|
coords.Longitude,
|
||||||
|
coords.Longitude,
|
||||||
|
).As("distance_sq"),
|
||||||
|
).
|
||||||
|
Order(
|
||||||
|
goqu.I("distance_sq").Asc(),
|
||||||
|
goqu.I("population").Desc(),
|
||||||
|
).
|
||||||
|
Limit(50)
|
||||||
|
|
||||||
|
var rows []locationRow
|
||||||
|
if err := q.ScanStructsContext(ctx, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query locations by coordinates from cities database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
locations := make([]prayer.Location, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
locations = append(locations, prayer.Location{
|
||||||
|
ID: row.ID,
|
||||||
|
Name: row.Name,
|
||||||
|
ASCIIName: row.ASCIIName,
|
||||||
|
AlternateNames: row.AlternateNames,
|
||||||
|
CountryCode: row.CountryCode,
|
||||||
|
Latitude: row.Latitude,
|
||||||
|
Longitude: row.Longitude,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,8 +35,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<input type='text'
|
<div class='location-search'>
|
||||||
x-model='locationId'>
|
<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'>
|
<template x-if='todayTimes'>
|
||||||
<div class='current-salath text--center'>
|
<div class='current-salath text--center'>
|
||||||
@@ -95,4 +134,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+132
-96
@@ -1,29 +1,115 @@
|
|||||||
const app = () => ({
|
const app = () => ({
|
||||||
futureTimes: [],
|
futureTimes: [],
|
||||||
|
selectedLocation: Alpine.$persist(null),
|
||||||
locationId: Alpine.$persist('11002'),
|
|
||||||
lastUpdated: Alpine.$persist(null),
|
lastUpdated: Alpine.$persist(null),
|
||||||
|
searchQuery: "",
|
||||||
|
searchResults: [],
|
||||||
|
searchOpen: false,
|
||||||
|
searchLoading: false,
|
||||||
|
searchError: "",
|
||||||
|
searchDebounceTimer: null,
|
||||||
userMinutes: 0,
|
userMinutes: 0,
|
||||||
now: new Date(),
|
now: new Date(),
|
||||||
debug: location.hash === '#debug',
|
debug: location.hash === "#debug",
|
||||||
geolocation: null,
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.refreshIfStale();
|
if (this.selectedLocation) {
|
||||||
|
this.searchQuery = this.formatLocationLabel(this.selectedLocation);
|
||||||
|
await this.refreshPrayerTimes();
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.now = new Date();
|
this.now = new Date();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
getUserLocation()
|
try {
|
||||||
.then(loc => {
|
const coords = await getUserLocation();
|
||||||
this.geolocation = {latitude: loc.latitude, longitude: loc.longitude};
|
await this.selectNearestLocation(coords.latitude, coords.longitude);
|
||||||
this.refreshIfStale();
|
} catch (_error) {
|
||||||
})
|
// Ignore geolocation errors and rely on manual search.
|
||||||
.catch(() => this.geolocation = null)
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onHash() {
|
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() {
|
get userNow() {
|
||||||
@@ -40,90 +126,66 @@ const app = () => ({
|
|||||||
return formatTime(this.userNow);
|
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() {
|
get todayTimes() {
|
||||||
if (this.futureTimes.length === 0) {
|
if (this.futureTimes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
return new PrayerTimes(this.futureTimes[0], () => this.userNow);
|
||||||
},
|
|
||||||
|
|
||||||
translate(key, lang) {
|
|
||||||
return translations[key][lang] ?? key
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
class PrayerTimes {
|
class PrayerTimes {
|
||||||
static salaths = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha'];
|
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||||
static translations = {
|
static translations = {
|
||||||
fajr: {tr: 'İmsak', de: 'Frühgebet', ar: 'صلاة الفجر'},
|
fajr: {tr: "İmsak", de: "Fruehgebet", ar: "صلاة الفجر"},
|
||||||
sunrise: {tr: 'Güneş', de: 'Sonnenaufgang', ar: 'الشروق'},
|
sunrise: {tr: "Günes", de: "Sonnenaufgang", ar: "الشروق"},
|
||||||
dhuhr: {tr: 'Öğle', de: 'Mittagsgebet', ar: 'صلاة الظهر'},
|
dhuhr: {tr: "Öğle", de: "Mittagsgebet", ar: "صلاة الظهر"},
|
||||||
asr: {tr: 'İkindi', de: 'Nachmittagsgebet', ar: 'صلاة العصر'},
|
asr: {tr: "İkindi", de: "Nachmittagsgebet", ar: "صلاة العصر"},
|
||||||
maghrib: {tr: 'Akşam', de: 'Abendgebet', ar: 'صلاة المغرب'},
|
maghrib: {tr: "Aksam", de: "Abendgebet", ar: "صلاة المغرب"},
|
||||||
isha: {tr: 'Yatsı', de: 'Nachtgebet', ar: 'صلاة العشاء'},
|
isha: {tr: "Yatsı", de: "Nachtgebet", ar: "صلاة العشاء"}
|
||||||
}
|
};
|
||||||
|
|
||||||
constructor({date, ...rest}, clock = () => new Date()) {
|
constructor({date, ...rest}, clock = () => new Date()) {
|
||||||
this.date = date;
|
this.date = date;
|
||||||
this.clock = clock
|
this.clock = clock;
|
||||||
this.salathTimes = rest
|
this.salathTimes = rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
get times() {
|
get times() {
|
||||||
const now = this.clock()
|
const now = this.clock();
|
||||||
return PrayerTimes.salaths.map(k => {
|
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 startsAt = new Date(this.date.replace('T00:00', `T${this.salathTimes[k]}`).replace(/Z$/, ''));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
salath: k,
|
salath: k,
|
||||||
name: lang => PrayerTimes.translations[k][lang] ?? '??',
|
name: (lang) => PrayerTimes.translations[k][lang] ?? "??",
|
||||||
startsAt,
|
startsAt,
|
||||||
timeLocal: this.salathTimes[k],
|
timeLocal: this.salathTimes[k],
|
||||||
get untilSeconds() {
|
get untilSeconds() {
|
||||||
let untilSeconds = (startsAt - now) / 1000;
|
const untilSeconds = (startsAt - now) / 1000;
|
||||||
return now > startsAt ? 0 : untilSeconds;
|
return now > startsAt ? 0 : untilSeconds;
|
||||||
},
|
},
|
||||||
get untilHuman() {
|
get untilHuman() {
|
||||||
return formatDuration(this.untilSeconds)
|
return formatDuration(this.untilSeconds);
|
||||||
},
|
}
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentSalath() {
|
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) {
|
if (current === undefined) {
|
||||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
|
||||||
const prevDay = new Date(this.date);
|
const prevDay = new Date(this.date);
|
||||||
prevDay.setDate(prevDay.getDate() - 1);
|
prevDay.setDate(prevDay.getDate() - 1);
|
||||||
|
|
||||||
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
current = new PrayerTimes({date: prevDay.toISOString(), ...this.salathTimes}, this.clock).times.at(-1);
|
||||||
}
|
}
|
||||||
return current
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
get nextSalath() {
|
get nextSalath() {
|
||||||
let next = this.times
|
let next = this.times.filter((it) => it.untilSeconds > 0)[0];
|
||||||
.filter(it => it.untilSeconds > 0)[0]
|
|
||||||
if (next === undefined) {
|
if (next === undefined) {
|
||||||
// we're in isha -> today's fajr is almost the same as tomorrows
|
|
||||||
const nextDay = new Date(this.date);
|
const nextDay = new Date(this.date);
|
||||||
nextDay.setDate(nextDay.getDate() + 1);
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
|
||||||
@@ -131,24 +193,16 @@ class PrayerTimes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...next,
|
...next
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {number} seconds
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
const d = new Date(0, 0, 0, 0, 0, seconds);
|
const d = new Date(0, 0, 0, 0, 0, seconds);
|
||||||
return formatTime(d);
|
return formatTime(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Date} then
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatTime(then) {
|
function formatTime(then) {
|
||||||
return new Intl.DateTimeFormat(navigator.language, {
|
return new Intl.DateTimeFormat(navigator.language, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
@@ -157,10 +211,6 @@ function formatTime(then) {
|
|||||||
}).format(then);
|
}).format(then);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Date} then
|
|
||||||
* @return {string}
|
|
||||||
* */
|
|
||||||
function formatDate(then) {
|
function formatDate(then) {
|
||||||
return new Intl.DateTimeFormat(navigator.language, {
|
return new Intl.DateTimeFormat(navigator.language, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -169,42 +219,28 @@ function formatDate(then) {
|
|||||||
}).format(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 = {}) {
|
async function fetchJSON(url, req = {}) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...req,
|
...req
|
||||||
})
|
});
|
||||||
return res.json()
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`request failed with status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return {Promise<GeolocationCoordinates>}
|
|
||||||
* */
|
|
||||||
function getUserLocation() {
|
function getUserLocation() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject("Geolocation is not supported by this browser.");
|
reject(new Error("Geolocation is not supported by this browser."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => resolve(position.coords),
|
(position) => resolve(position.coords),
|
||||||
(error) => reject(error.message)
|
(error) => reject(error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,60 @@ body {
|
|||||||
font-family: 'Inter', sans-serif;
|
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 {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -39,6 +93,21 @@ p + p {
|
|||||||
margin: auto;
|
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 {
|
.salath-table {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -85,4 +154,4 @@ td {
|
|||||||
.current-salath {
|
.current-salath {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user