Compare commits
10
Commits
612c6ebde5
...
4b78236a01
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b78236a01 | ||
|
|
4e635bd27c | ||
|
|
2183976b85 | ||
|
|
17c511f973 | ||
|
|
cec7f1566e | ||
|
|
c3bf3eca8d | ||
|
|
dc25633f61 | ||
|
|
e295a997a1 | ||
|
|
b5aecf4304 | ||
|
|
b55fc2333c |
No files matched your search
Executable
+45
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,7 +9,13 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +26,7 @@ RUN apt-get update \
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/prayertimes /usr/local/bin/prayertimes
|
||||
COPY --from=seeder /data/prayertimes.sqlite3 /data/prayertimes.sqlite3
|
||||
|
||||
ENV PORT=8000
|
||||
ENV DB_PATH=/data/prayertimes.sqlite3
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
.wrangler/
|
||||
node_modules/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "cfgeo",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.90.0"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "cfgeo"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
+1
-8
@@ -1,16 +1,9 @@
|
||||
services:
|
||||
prayertimes:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/abdusco/prayertimes:latest
|
||||
container_name: prayertimes
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
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
|
||||
@@ -261,6 +261,16 @@ func New(services Services) *fiber.App {
|
||||
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()}))
|
||||
|
||||
return app
|
||||
|
||||
@@ -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>`
|
||||
@@ -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
|
||||
+81
-43
@@ -4,7 +4,7 @@
|
||||
<meta charset='UTF-8'>
|
||||
<meta name='viewport'
|
||||
content='width=device-width, initial-scale=1.0'>
|
||||
<title>Document</title>
|
||||
<title>Prayer Times</title>
|
||||
<script defer
|
||||
src='main.js'></script>
|
||||
<script defer
|
||||
@@ -24,7 +24,8 @@
|
||||
<body>
|
||||
|
||||
<div x-data='app()'
|
||||
@hashchange.window='onHash'>
|
||||
@hashchange.window='onHash'
|
||||
class='app'>
|
||||
<template x-if='debug'>
|
||||
<div>
|
||||
<label>clock: <input type='range'
|
||||
@@ -35,48 +36,84 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'>
|
||||
<header class='app-bar'>
|
||||
<div class='app-bar__brand'>
|
||||
<span class='app-bar__icon' aria-hidden='true'>☽</span>
|
||||
<span class='app-bar__title'
|
||||
x-text="t('appTitle')"></span>
|
||||
</div>
|
||||
|
||||
<template x-if='searchOpen'>
|
||||
<div class='location-search__dropdown'>
|
||||
<template x-if='searchLoading'>
|
||||
<div class='location-search__state'>Searching...</div>
|
||||
</template>
|
||||
<div class='location-search'>
|
||||
<input type='search'
|
||||
class='location-search__input'
|
||||
:placeholder="t('searchPlaceholder')"
|
||||
x-model='searchQuery'
|
||||
@input='onSearchInput'
|
||||
@focus='searchOpen = searchResults.length > 0'
|
||||
@click.outside='searchOpen = false'>
|
||||
|
||||
<template x-if='!searchLoading && searchError'>
|
||||
<div class='location-search__state'
|
||||
x-text='searchError'></div>
|
||||
</template>
|
||||
<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='!searchLoading && !searchError && searchResults.length === 0'>
|
||||
<div class='location-search__state'>No results</div>
|
||||
</template>
|
||||
<template x-if='searchOpen'>
|
||||
<div class='location-search__dropdown'>
|
||||
<template x-if='searchLoading'>
|
||||
<div class='location-search__state'
|
||||
x-text="t('searching')"></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='!searchLoading && searchError'>
|
||||
<div class='location-search__state'
|
||||
x-text='t(searchError)'></div>
|
||||
</template>
|
||||
|
||||
<template x-if='!searchLoading && !searchError && searchResults.length === 0'>
|
||||
<div class='location-search__state'
|
||||
x-text="t('noResults')"></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>
|
||||
</header>
|
||||
|
||||
<main class='app-main'>
|
||||
<template x-if='todayTimes'>
|
||||
<div class='current-salath text--center'>
|
||||
<p>
|
||||
@@ -142,11 +179,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<div class='text--center text--numeric'>
|
||||
<p><span class='clock'
|
||||
x-text='formatTime(now)'></span></p>
|
||||
</div>
|
||||
<footer class='app-footer text--center text--numeric'>
|
||||
<span class='clock'
|
||||
x-text='formatTime(now)'></span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+133
-6
@@ -1,3 +1,6 @@
|
||||
const CFGEO_ENDPOINT = "https://cfgeo.abdusco.workers.dev/";
|
||||
const CFGEO_TIMEOUT_MS = 2000;
|
||||
|
||||
const app = () => ({
|
||||
futureTimes: [],
|
||||
selectedLocation: Alpine.$persist(null),
|
||||
@@ -12,6 +15,13 @@ const app = () => ({
|
||||
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();
|
||||
@@ -27,13 +37,30 @@ const app = () => ({
|
||||
}, 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.
|
||||
await this.resolveDefaultLocation();
|
||||
}
|
||||
},
|
||||
|
||||
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});
|
||||
}
|
||||
},
|
||||
|
||||
onHash() {
|
||||
@@ -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() {
|
||||
this.searchError = "";
|
||||
if (this.searchDebounceTimer) {
|
||||
@@ -55,6 +95,9 @@ const app = () => ({
|
||||
if (query === "") {
|
||||
this.searchResults = [];
|
||||
this.searchOpen = false;
|
||||
if (this.selectedLocation) {
|
||||
this.clearLocation();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +114,7 @@ const app = () => ({
|
||||
this.searchResults = response.locations ?? [];
|
||||
} catch (_error) {
|
||||
this.searchResults = [];
|
||||
this.searchError = "Failed to search locations.";
|
||||
this.searchError = "searchFailed";
|
||||
} finally {
|
||||
this.searchLoading = false;
|
||||
}
|
||||
@@ -97,6 +140,16 @@ const app = () => ({
|
||||
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 "";
|
||||
@@ -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 {
|
||||
static salaths = ["fajr", "sunrise", "dhuhr", "asr", "maghrib", "isha"];
|
||||
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) {
|
||||
return String(tag).toLowerCase().split("-")[0];
|
||||
}
|
||||
@@ -367,3 +485,12 @@ function setURLCoords(latitude, longitude) {
|
||||
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());
|
||||
}
|
||||
+199
-28
@@ -2,37 +2,140 @@
|
||||
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 {
|
||||
line-height: 1.6;
|
||||
line-height: 1.4;
|
||||
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 {
|
||||
position: relative;
|
||||
max-width: 38rem;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.location-search__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 0.25rem);
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
z-index: 5;
|
||||
box-shadow: 0 10px 30px rgba(22, 48, 43, 0.12);
|
||||
}
|
||||
|
||||
.location-search__results {
|
||||
@@ -44,21 +147,30 @@ body {
|
||||
.location-search__item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 0.625rem 0.75rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.location-search__item:hover {
|
||||
background: #fafafa;
|
||||
background: #f4f1e4;
|
||||
}
|
||||
|
||||
.location-search__state {
|
||||
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 {
|
||||
@@ -66,7 +178,7 @@ p {
|
||||
}
|
||||
|
||||
p + p {
|
||||
margin-top: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.text--numeric {
|
||||
@@ -87,49 +199,92 @@ p + p {
|
||||
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 {
|
||||
padding: 0 2rem;
|
||||
max-width: 66%;
|
||||
margin: auto;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
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) {
|
||||
.table-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.salath-table {
|
||||
font-size: 1.375rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 2rem;
|
||||
font-size: clamp(1.1rem, 2.4vh, 1.625rem);
|
||||
font-weight: 600;
|
||||
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
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 {
|
||||
padding-right: 0.5rem;
|
||||
white-space: nowrap;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.cell-times {
|
||||
padding-left: 0.5rem;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.salath-name {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.salath-name-group {
|
||||
@@ -162,19 +317,35 @@ td {
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: clamp(0.5rem, 2vh, 1rem) 1rem clamp(1rem, 4vh, 2rem);
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 4rem;
|
||||
font-size: clamp(1.75rem, 5vh, 3rem);
|
||||
font-weight: bold;
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.current-salath {
|
||||
flex: 0 0 auto;
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
font-size: clamp(0.95rem, 2.2vh, 1.25rem);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
Reference in new issue
Block a user