Compare commits

..
10 Commits
Author SHA1 Message Date
abdus 4b78236a01 fix(ci): Match scoped conventional-commit prefixes in changelog script
The type regex only matched "type: ", so scoped commits like
"fix(web): ..." all fell into the catch-all Other section instead of
their proper one.
2026-10-08 21:42:40 +02:00
abdus 4e635bd27c chore(docker): Pull the published image instead of building locally
Use the ghcr.io image built and published by CI rather than a local
docker build context.
2026-10-08 21:40:58 +02:00
abdus 2183976b85 ci: Add multi-arch Docker build/publish and tagged release workflow
Build and push linux/amd64 + linux/arm64 images to GHCR on every push
to master and on v* tags. On v* tags, also generate a changelog from
conventional commits and create a GitHub release.
2026-10-08 21:29:16 +02:00
abdus 17c511f973 docs: Add minimal README 2026-10-08 21:29:11 +02:00
abdus cec7f1566e feat(api): Add Swagger UI for v1 endpoints
Serve interactive docs at /api, covering only the current /api/v1/*
endpoints. Deprecated /api/diyanet/* endpoints are left undocumented.
2026-10-08 21:29:07 +02:00
abdus c3bf3eca8d feat(docker): Preseed cities database and fix multi-arch builds
Bake the GeoNames cities database into the image at build time instead
of relying on host-mounted sqlite files, and fix the Go build stage to
cross-compile for the requested target platform instead of hardcoding
amd64 (was silently broken for arm64 builds).
2026-10-08 21:29:02 +02:00
abdus dc25633f61 feat(web): Add Cloudflare Workers geolocation fallback
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.
2026-10-08 21:11:07 +02:00
abdus e295a997a1 fix(web): Fix mobile header padding and narrow-viewport table overflow
Restore vh-based top padding on the app bar at <=640px (previously
overridden by a fixed value), and collapse secondary-language prayer
name alternatives at <=480px so times never scroll off-screen.
2026-10-08 21:09:19 +02:00
abdus b5aecf4304 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.
2026-10-08 21:00:44 +02:00
abdus b55fc2333c feat(web): Give homepage a modern Islamic theme
Deep teal/gold palette, crescent + title combined into a single app
bar with the location search, and a no-scroll flex layout that fits
the clock/table/search in the viewport on both desktop and mobile.
2026-10-08 20:48:41 +02:00
15 changed files with 797 additions and 54 deletions

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./generate_changelog.sh [<git-log-range>]
#
# Groups commits by their conventional-commit prefix (feat, fix, ...) into
# sections. Commits within each section are sorted oldest-first. Commits
# that don't match a known prefix land in an "Other" section.
range="${1:-}"
types="feat fix refactor perf docs test ci build chore"
label_for() {
case "$1" in
feat) echo "Features" ;;
fix) echo "Fixes" ;;
refactor) echo "Refactoring" ;;
perf) echo "Performance" ;;
docs) echo "Documentation" ;;
test) echo "Tests" ;;
ci) echo "CI" ;;
build) echo "Build" ;;
chore) echo "Chores" ;;
*) echo "Other" ;;
esac
}
commits=$(git log ${range} --reverse --pretty='format:%s')
known_pattern="^(feat|fix|refactor|perf|docs|test|ci|build|chore)(\([^)]*\))?!?: "
first_section=1
for type in $types other; do
if [ "$type" = "other" ]; then
entries=$(printf '%s\n' "$commits" | grep -v -E "$known_pattern" | sed 's/^/- /' || true)
else
entries=$(printf '%s\n' "$commits" | grep -E "^${type}(\([^)]*\))?!?: " | sed -E "s/^${type}(\([^)]*\))?!?: /- /" || true)
fi
[ -z "$entries" ] && continue
[ "$first_section" -eq 0 ] && echo
first_section=0
printf '### %s\n\n%s\n' "$(label_for "$type")" "$entries"
done
+88
View File
@@ -0,0 +1,88 @@
name: Docker build & release
on:
push:
branches: [master]
tags: ["v*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
needs: build-and-push
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
prev_tag=$(git describe --tags --abbrev=0 "${{ github.ref_name }}^" 2>/dev/null || true)
if [ -n "$prev_tag" ]; then
range="${prev_tag}..${{ github.ref_name }}"
else
range=""
fi
.github/generate_changelog.sh "$range" > /tmp/changelog.md
cat /tmp/changelog.md
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${{ github.ref_name }}" \
--title "${{ github.ref_name }}" \
--notes-file /tmp/changelog.md
+12 -2
View File
@@ -1,4 +1,7 @@
FROM golang:1.25-bookworm AS builder FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src WORKDIR /src
@@ -6,7 +9,13 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/prayertimes . RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/prayertimes .
FROM --platform=$BUILDPLATFORM python:3.12-slim AS seeder
WORKDIR /seed
COPY scripts/seed_cities.py .
RUN python3 seed_cities.py --db /data/prayertimes.sqlite3
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -17,6 +26,7 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY --from=builder /out/prayertimes /usr/local/bin/prayertimes COPY --from=builder /out/prayertimes /usr/local/bin/prayertimes
COPY --from=seeder /data/prayertimes.sqlite3 /data/prayertimes.sqlite3
ENV PORT=8000 ENV PORT=8000
ENV DB_PATH=/data/prayertimes.sqlite3 ENV DB_PATH=/data/prayertimes.sqlite3
+39
View File
@@ -0,0 +1,39 @@
# prayertimes
Prayer times and location search backed by Diyanet data, with an
astronomical-calculation fallback when the upstream API is unavailable.
## Running
```bash
go run .
```
Or via Docker (the image ships with a preseeded GeoNames cities database,
no extra setup needed):
```bash
docker build -t prayertimes .
docker run -p 8000:8000 prayertimes
```
### Environment variables
| Variable | Default | Description |
|-----------|-------------------------------|-----------------------|
| `PORT` | `8000` | HTTP listen port |
| `DB_PATH` | `./prayertimes.sqlite3` | Path to the cities DB |
## API
Interactive API docs (Swagger UI) are served at `/api`, covering the
current `/api/v1/*` endpoints. Older `/api/diyanet/*` endpoints are
deprecated and intentionally left out of the docs.
## Development
Refresh the cities database from GeoNames:
```bash
python3 scripts/seed_cities.py --db ./prayertimes.sqlite3
```
+2
View File
@@ -0,0 +1,2 @@
.wrangler/
node_modules/
+11
View File
@@ -0,0 +1,11 @@
{
"name": "cfgeo",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"wrangler": "^3.90.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
export default {
async fetch(request) {
const cf = request.cf || {};
const body = {
latitude: cf.latitude ? Number(cf.latitude) : null,
longitude: cf.longitude ? Number(cf.longitude) : null,
city: cf.city ?? null,
region: cf.region ?? null,
country: cf.country ?? null,
timezone: cf.timezone ?? null,
postalCode: cf.postalCode ?? null,
};
return new Response(JSON.stringify(body), {
headers: {
'content-type': 'application/json',
'access-control-allow-origin': '*',
'cache-control': 'no-store',
},
});
},
};
+3
View File
@@ -0,0 +1,3 @@
name = "cfgeo"
main = "src/index.js"
compatibility_date = "2024-01-01"
+1 -8
View File
@@ -1,16 +1,9 @@
services: services:
prayertimes: prayertimes:
build: image: ghcr.io/abdusco/prayertimes:latest
context: .
dockerfile: Dockerfile
container_name: prayertimes container_name: prayertimes
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
PORT: "8000" PORT: "8000"
DB_PATH: "/data/prayertimes.sqlite3"
volumes:
- ./prayertimes.sqlite3:/data/prayertimes.sqlite3
- ./prayertimes.sqlite3-wal:/data/prayertimes.sqlite3-wal
- ./prayertimes.sqlite3-shm:/data/prayertimes.sqlite3-shm
restart: unless-stopped restart: unless-stopped
+10
View File
@@ -261,6 +261,16 @@ func New(services Services) *fiber.App {
return ctx.JSON(mapped) return ctx.JSON(mapped)
}) })
app.Get("/api/openapi.yaml", func(ctx fiber.Ctx) error {
ctx.Response().Header.Set(fiber.HeaderContentType, "application/yaml")
return ctx.Send(openapiSpec)
})
app.Get("/api", func(ctx fiber.Ctx) error {
ctx.Response().Header.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
return ctx.SendString(swaggerHTML)
})
app.Use("/", static.New("", static.Config{FS: templates.FS()})) app.Use("/", static.New("", static.Config{FS: templates.FS()}))
return app return app
+27
View File
@@ -0,0 +1,27 @@
package api
import _ "embed"
//go:embed openapi.yaml
var openapiSpec []byte
const swaggerHTML = `<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Prayer Times API</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
SwaggerUIBundle({
url: "/api/openapi.yaml",
dom_id: "#swagger-ui",
});
};
</script>
</body>
</html>`
+156
View File
@@ -0,0 +1,156 @@
openapi: 3.0.3
info:
title: Prayer Times API
version: "1"
description: Prayer times and location lookup, backed by Diyanet data.
externalDocs:
description: Source code
url: https://git.abdus.dev/abdus/prayertimes-go/
servers:
- url: /
paths:
/api/v1/diyanet/location:
get:
summary: Search locations
description: Search locations by free-text query, or find the nearest location to a coordinate pair.
parameters:
- name: query
in: query
description: Free-text location search. Mutually exclusive with latitude/longitude.
schema:
type: string
- name: latitude
in: query
description: Required together with longitude when query is omitted.
schema:
type: number
format: double
- name: longitude
in: query
description: Required together with latitude when query is omitted.
schema:
type: number
format: double
responses:
"200":
description: Matching locations.
content:
application/json:
schema:
type: object
properties:
locations:
type: array
items:
$ref: "#/components/schemas/Location"
"400":
description: Missing both query and coordinates.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/api/v1/diyanet/prayertimes:
get:
summary: Get prayer times
description: Get prayer times by location ID, or by coordinates.
parameters:
- name: location_id
in: query
description: Mutually exclusive with latitude/longitude.
schema:
type: string
- name: latitude
in: query
schema:
type: number
format: double
- name: longitude
in: query
schema:
type: number
format: double
- name: utc
in: query
description: Return timestamps in UTC instead of the location's local timezone.
schema:
type: boolean
default: false
responses:
"200":
description: Prayer times for the resolved location.
content:
application/json:
schema:
type: object
properties:
location:
$ref: "#/components/schemas/Location"
prayertimes:
type: array
items:
$ref: "#/components/schemas/PrayerTimes"
"400":
description: Missing both location_id and coordinates.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"422":
description: Invalid location.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
schemas:
Location:
type: object
properties:
id:
type: integer
name:
type: string
ascii_name:
type: string
alternate_names:
type: string
country_code:
type: string
latitude:
type: number
format: double
longitude:
type: number
format: double
timezone:
type: string
PrayerTimes:
type: object
properties:
date:
type: string
format: date
date_hijri:
type: string
fajr:
type: string
example: "05:47"
sunrise:
type: string
dhuhr:
type: string
asr:
type: string
sunset:
type: string
maghrib:
type: string
isha:
type: string
Error:
type: object
properties:
message:
type: string
+48 -10
View File
@@ -4,7 +4,7 @@
<meta charset='UTF-8'> <meta charset='UTF-8'>
<meta name='viewport' <meta name='viewport'
content='width=device-width, initial-scale=1.0'> content='width=device-width, initial-scale=1.0'>
<title>Document</title> <title>Prayer Times</title>
<script defer <script defer
src='main.js'></script> src='main.js'></script>
<script defer <script defer
@@ -24,7 +24,8 @@
<body> <body>
<div x-data='app()' <div x-data='app()'
@hashchange.window='onHash'> @hashchange.window='onHash'
class='app'>
<template x-if='debug'> <template x-if='debug'>
<div> <div>
<label>clock: <input type='range' <label>clock: <input type='range'
@@ -35,28 +36,62 @@
</div> </div>
</template> </template>
<header class='app-bar'>
<div class='app-bar__brand'>
<span class='app-bar__icon' aria-hidden='true'>&#9789;</span>
<span class='app-bar__title'
x-text="t('appTitle')"></span>
</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'>
@@ -76,7 +111,9 @@
</div> </div>
</template> </template>
</div> </div>
</header>
<main class='app-main'>
<template x-if='todayTimes'> <template x-if='todayTimes'>
<div class='current-salath text--center'> <div class='current-salath text--center'>
<p> <p>
@@ -142,11 +179,12 @@
</table> </table>
</div> </div>
</template> </template>
</main>
<div class='text--center text--numeric'> <footer class='app-footer text--center text--numeric'>
<p><span class='clock' <span class='clock'
x-text='formatTime(now)'></span></p> x-text='formatTime(now)'></span>
</div> </footer>
</div> </div>
</body> </body>
</html> </html>
+133 -6
View File
@@ -1,3 +1,6 @@
const CFGEO_ENDPOINT = "https://cfgeo.abdusco.workers.dev/";
const CFGEO_TIMEOUT_MS = 2000;
const app = () => ({ const app = () => ({
futureTimes: [], futureTimes: [],
selectedLocation: Alpine.$persist(null), selectedLocation: Alpine.$persist(null),
@@ -12,6 +15,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();
@@ -27,12 +37,29 @@ const app = () => ({
}, 500); }, 500);
if (!urlCoords) { if (!urlCoords) {
try { await this.resolveDefaultLocation();
const coords = await getUserLocation();
await this.selectNearestLocation(coords.latitude, coords.longitude, {updateURL: true});
} catch (_error) {
// Ignore geolocation errors and rely on manual search.
} }
},
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});
} }
}, },
@@ -45,6 +72,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 +95,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 +114,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 +140,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 +226,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 = {
@@ -318,6 +416,26 @@ function getUserLocation() {
}); });
} }
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) { function normalizeLanguageTag(tag) {
return String(tag).toLowerCase().split("-")[0]; return String(tag).toLowerCase().split("-")[0];
} }
@@ -367,3 +485,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());
}
+199 -28
View File
@@ -2,37 +2,140 @@
box-sizing: border-box; box-sizing: border-box;
} }
:root {
--color-bg: #f6f3ea;
--color-surface: #ffffff;
--color-ink: #16302b;
--color-ink-muted: #5c6f68;
--color-primary: #0f4c42;
--color-primary-dark: #0a3430;
--color-gold: #b8902f;
--color-gold-soft: #e3cd8f;
--color-border: #e1dcc8;
--color-current: #b8562f;
}
html, body {
height: 100%;
}
body { body {
line-height: 1.6; line-height: 1.4;
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
color: var(--color-ink);
background-color: var(--color-bg);
background-image:
radial-gradient(circle at 1px 1px, rgba(15, 76, 66, 0.08) 1px, transparent 0),
linear-gradient(160deg, #f8f5ec 0%, #f1ecda 100%);
background-size: 24px 24px, 100% 100%;
margin: 0;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
}
.app-bar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: clamp(1rem, 4vh, 2rem) 1.25rem 0.875rem;
}
.app-bar__brand {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 0 0 auto;
}
.app-bar__icon {
font-size: 1.375rem;
line-height: 1;
color: var(--color-gold);
}
.app-bar__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-primary-dark);
white-space: nowrap;
} }
.location-search { .location-search {
position: relative; position: relative;
max-width: 38rem; display: flex;
margin: 1rem auto; align-items: center;
padding: 0 1rem; gap: 0.5rem;
width: 100%;
max-width: 24rem;
} }
.location-search__input { .location-search__input {
flex: 1 1 auto;
min-width: 0;
width: 100%; width: 100%;
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 0.75rem; border-radius: 0.75rem;
padding: 0.75rem 0.875rem; padding: 0.5rem 0.875rem;
font: inherit; font: inherit;
background: var(--color-surface);
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 {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(15, 76, 66, 0.15);
} }
.location-search__dropdown { .location-search__dropdown {
position: absolute; position: absolute;
left: 1rem; left: 0;
right: 1rem; right: 0;
top: calc(100% + 0.25rem); top: calc(100% + 0.25rem);
background: #fff; background: var(--color-surface);
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 0.75rem; border-radius: 0.75rem;
max-height: 18rem; max-height: 18rem;
overflow: auto; overflow: auto;
z-index: 5; z-index: 5;
box-shadow: 0 10px 30px rgba(22, 48, 43, 0.12);
} }
.location-search__results { .location-search__results {
@@ -44,21 +147,30 @@ body {
.location-search__item { .location-search__item {
width: 100%; width: 100%;
border: 0; border: 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--color-border);
background: transparent; background: transparent;
text-align: left; text-align: left;
padding: 0.625rem 0.75rem; padding: 0.625rem 0.75rem;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
color: var(--color-ink);
} }
.location-search__item:hover { .location-search__item:hover {
background: #fafafa; background: #f4f1e4;
} }
.location-search__state { .location-search__state {
padding: 0.75rem; padding: 0.75rem;
color: #666; color: var(--color-ink-muted);
}
@media (max-width: 640px) {
.app-bar {
flex-direction: column;
gap: 0.625rem;
padding: clamp(1rem, 4vh, 2rem) 1rem 0.75rem;
}
} }
p { p {
@@ -66,7 +178,7 @@ p {
} }
p + p { p + p {
margin-top: 1rem; margin-top: 0.5rem;
} }
.text--numeric { .text--numeric {
@@ -87,49 +199,92 @@ p + p {
font-size: 1.5em; font-size: 1.5em;
} }
.app-main {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
gap: clamp(0.5rem, 2vh, 1.25rem);
padding: clamp(0.5rem, 2vh, 1rem) 1rem;
overflow: hidden;
}
.table-wrapper { .table-wrapper {
padding: 0 2rem; flex: 0 1 auto;
max-width: 66%; min-height: 0;
margin: auto; overflow: auto;
width: 100%;
max-width: 34rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 1rem;
box-shadow: 0 10px 30px rgba(22, 48, 43, 0.08);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.table-wrapper { .table-wrapper {
max-width: 100%; max-width: 100%;
padding: 0 1rem;
} }
.salath-table { .salath-table {
font-size: 1.375rem; font-size: 1.25rem;
} }
.clock { .clock {
font-size: 3rem; font-size: 2.5rem;
}
}
@media (max-width: 480px) {
.salath-table {
table-layout: fixed;
}
.cell-names {
width: 62%;
white-space: normal;
overflow: hidden;
}
.cell-times {
width: 38%;
}
.salath-name-alternatives {
display: none;
} }
} }
.salath-table { .salath-table {
font-size: 2rem; font-size: clamp(1.1rem, 2.4vh, 1.625rem);
font-weight: 600; font-weight: 600;
width: 100%; width: 100%;
vertical-align: middle; vertical-align: middle;
border-collapse: collapse;
} }
td { td {
padding: 0.25rem 0.5rem; padding: clamp(0.4rem, 1.3vh, 0.75rem) 1.25rem;
}
tr.salath + tr.salath td {
border-top: 1px solid var(--color-border);
} }
.cell-names { .cell-names {
padding-right: 0.5rem;
white-space: nowrap; white-space: nowrap;
color: var(--color-primary-dark);
} }
.cell-times { .cell-times {
padding-left: 0.5rem;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
} color: var(--color-ink);
.salath-name {
} }
.salath-name-group { .salath-name-group {
@@ -162,19 +317,35 @@ td {
} }
.current { .current {
color: tomato; color: var(--color-current);
}
tr.current td {
background: #faf4e3;
}
tr.current .cell-names {
box-shadow: inset 4px 0 0 var(--color-gold);
} }
.salath-time { .salath-time {
font-weight: 700; font-weight: 700;
} }
.app-footer {
flex: 0 0 auto;
padding: clamp(0.5rem, 2vh, 1rem) 1rem clamp(1rem, 4vh, 2rem);
}
.clock { .clock {
font-size: 4rem; font-size: clamp(1.75rem, 5vh, 3rem);
font-weight: bold; font-weight: bold;
color: var(--color-primary-dark);
} }
.current-salath { .current-salath {
flex: 0 0 auto;
font-weight: bold; font-weight: bold;
font-size: 1.5rem; font-size: clamp(0.95rem, 2.2vh, 1.25rem);
color: var(--color-primary);
} }