feat: add consent-based GPS location
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Projekt
|
## Projekt
|
||||||
|
|
||||||
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości.
|
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżące pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznego API IMGW oraz oddzielnie oznaczoną prognozę modelową Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
|
||||||
|
|
||||||
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts i Lucide React. PWA korzysta z manifestu oraz własnego service workera.
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
|
|||||||
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
|
- Dodawaj `"use client"` tylko tam, gdzie komponent lub moduł korzysta z hooków, stanu przeglądarki albo interakcji.
|
||||||
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
||||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
||||||
|
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
|
||||||
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
|
||||||
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.
|
||||||
- Teksty interfejsu dodawaj równolegle po polsku i angielsku w `lib/i18n.tsx`. Nazw stacji i treści IMGW nie tłumacz automatycznie.
|
- Teksty interfejsu dodawaj równolegle po polsku i angielsku w `lib/i18n.tsx`. Nazw stacji i treści IMGW nie tłumacz automatycznie.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany l
|
|||||||
|
|
||||||
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
Wyszukiwarka na stronie głównej obsługuje miejscowości w całej Polsce. Nazwa miejscowości jest rozpoznawana przez Open-Meteo Geocoding API oparte o GeoNames, natomiast wyświetlany pomiar pogody nadal pochodzi wyłącznie z najbliższej rzeczywistej stacji IMGW. Interfejs jawnie pokazuje nazwę tej stacji oraz przybliżoną odległość.
|
||||||
|
|
||||||
|
Użytkownik może opcjonalnie udostępnić położenie GPS. Pozycja jest zaokrąglana do trzech miejsc po przecinku, czyli około 100 metrów, a nazwa miejscowości jest ustalana przez Nominatim / OpenStreetMap. Po zgodzie aplikacja wybiera lokalizację, najbliższą stację IMGW i prognozę. Geolocation API wymaga bezpiecznego kontekstu HTTPS. Wyjątkiem jest `localhost`; wejście z iPhone przez lokalny adres typu `http://192.168.x.x:3000` nie uruchomi systemowego pytania Safari.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Next.js z App Router i TypeScript
|
- Next.js z App Router i TypeScript
|
||||||
@@ -54,7 +56,9 @@ Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.o
|
|||||||
|
|
||||||
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
||||||
|
|
||||||
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`.
|
Opcjonalny reverse geocoding dla GPS korzysta z publicznego endpointu Nominatim: `https://nominatim.openstreetmap.org/reverse`. Wywołanie następuje wyłącznie po zgodzie użytkownika. Interfejs pokazuje atrybucję OpenStreetMap. Przed wdrożeniem o większym ruchu należy sprawdzić aktualną politykę użycia publicznej instancji Nominatim lub zastąpić ją własną usługą.
|
||||||
|
|
||||||
|
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`, a reverse geocoding GPS `app/api/locations/reverse/route.ts`.
|
||||||
|
|
||||||
## Ograniczenia API
|
## Ograniczenia API
|
||||||
|
|
||||||
|
|||||||
65
app/api/locations/reverse/route.ts
Normal file
65
app/api/locations/reverse/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
|
||||||
|
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
|
||||||
|
|
||||||
|
interface RawReverseLocation {
|
||||||
|
name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
address?: {
|
||||||
|
city?: string;
|
||||||
|
town?: string;
|
||||||
|
village?: string;
|
||||||
|
municipality?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCoordinate(value: string | null, min: number, max: number) {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
const coordinate = Number(value);
|
||||||
|
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? Number(coordinate.toFixed(3)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||||
|
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
|
||||||
|
const language = searchParams.get("language") === "en" ? "en" : "pl";
|
||||||
|
if (latitude === null || longitude === null) {
|
||||||
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
format: "jsonv2",
|
||||||
|
lat: String(latitude),
|
||||||
|
lon: String(longitude),
|
||||||
|
zoom: "10",
|
||||||
|
addressdetails: "1",
|
||||||
|
"accept-language": language,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${REVERSE_GEOCODING_URL}?${params}`, {
|
||||||
|
next: { revalidate: 86400 },
|
||||||
|
headers: { Accept: "application/json", "User-Agent": USER_AGENT },
|
||||||
|
});
|
||||||
|
if (!response.ok) return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
|
||||||
|
const data = await response.json() as RawReverseLocation;
|
||||||
|
const name = data.address?.city
|
||||||
|
?? data.address?.town
|
||||||
|
?? data.address?.village
|
||||||
|
?? data.address?.municipality
|
||||||
|
?? data.name
|
||||||
|
?? data.display_name?.split(",")[0]?.trim();
|
||||||
|
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
|
||||||
|
return NextResponse.json({
|
||||||
|
name,
|
||||||
|
province: data.address?.state ?? null,
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Reverse geocoding is unavailable." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
135
components/weather/current-location-control.tsx
Normal file
135
components/weather/current-location-control.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { ExternalLink, LoaderCircle, LocateFixed, MapPinned, ShieldAlert, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { fetchReverseLocation } from "@/lib/location-api";
|
||||||
|
import { findNearestSynopStation, type LocatedSynopStation } from "@/lib/location-utils";
|
||||||
|
import { useWeatherStore } from "@/lib/store";
|
||||||
|
|
||||||
|
const GPS_PROMPT_SEEN_KEY = "wtr:gps-prompt-seen";
|
||||||
|
|
||||||
|
function roundCoordinate(value: number) {
|
||||||
|
return Number(value.toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurrentLocationControl({ stations }: { stations: LocatedSynopStation[] }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
|
const selectLocation = useWeatherStore((state) => state.selectLocation);
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
const [isLocating, setIsLocating] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [isSecureContext, setIsSecureContext] = useState(true);
|
||||||
|
const autoLocated = useRef(false);
|
||||||
|
|
||||||
|
const dismissPrompt = useCallback(() => {
|
||||||
|
window.localStorage.setItem(GPS_PROMPT_SEEN_KEY, "true");
|
||||||
|
setShowPrompt(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const locate = useCallback(() => {
|
||||||
|
dismissPrompt();
|
||||||
|
setMessage(null);
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
setMessage(t("location.gpsSecureContext"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
setMessage(t("location.gpsUnavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!stations.length) {
|
||||||
|
setMessage(t("location.gpsStationsPending"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLocating(true);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async ({ coords }) => {
|
||||||
|
const latitude = roundCoordinate(coords.latitude);
|
||||||
|
const longitude = roundCoordinate(coords.longitude);
|
||||||
|
try {
|
||||||
|
const place = await fetchReverseLocation(latitude, longitude, language).catch(() => ({
|
||||||
|
name: t("location.gpsFallbackName"),
|
||||||
|
province: null,
|
||||||
|
district: null,
|
||||||
|
}));
|
||||||
|
const nearest = findNearestSynopStation({ ...place, latitude, longitude }, stations);
|
||||||
|
if (!nearest) {
|
||||||
|
setMessage(t("location.gpsStationsPending"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectLocation(nearest);
|
||||||
|
setMessage(t("location.gpsSelected", { location: nearest.name }));
|
||||||
|
} catch {
|
||||||
|
setMessage(t("location.gpsPositionUnavailable"));
|
||||||
|
} finally {
|
||||||
|
setIsLocating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
setIsLocating(false);
|
||||||
|
setMessage(error.code === error.PERMISSION_DENIED
|
||||||
|
? t("location.gpsDenied")
|
||||||
|
: error.code === error.TIMEOUT
|
||||||
|
? t("location.gpsTimeout")
|
||||||
|
: t("location.gpsPositionUnavailable"));
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5 * 60 * 1000, timeout: 12_000 },
|
||||||
|
);
|
||||||
|
}, [dismissPrompt, language, selectLocation, stations, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
setIsSecureContext(window.isSecureContext);
|
||||||
|
if (window.localStorage.getItem(GPS_PROMPT_SEEN_KEY) !== "true") setShowPrompt(true);
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(animationFrame);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.isSecureContext || !stations.length || autoLocated.current || !navigator.permissions?.query) return;
|
||||||
|
navigator.permissions.query({ name: "geolocation" }).then((permission) => {
|
||||||
|
if (permission.state !== "granted" || autoLocated.current) return;
|
||||||
|
autoLocated.current = true;
|
||||||
|
locate();
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, [locate, stations.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{showPrompt && (
|
||||||
|
<div className="glass-subtle rounded-2xl p-3.5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-full bg-sky-500/10 p-2 text-sky-700 dark:text-sky-300"><MapPinned className="size-4" /></div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-semibold">{t("location.gpsPromptTitle")}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("location.gpsPromptDescription")}</p>
|
||||||
|
{!isSecureContext && <p className="mt-2 flex items-start gap-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300"><ShieldAlert className="mt-0.5 size-3.5 shrink-0" />{t("location.gpsSecureContext")}</p>}
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button type="button" onClick={locate} disabled={isLocating || !stations.length}>
|
||||||
|
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
|
{isLocating ? t("location.gpsLocating") : t("location.gpsAllow")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={dismissPrompt}><X className="size-4" />{t("location.gpsNotNow")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!showPrompt && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<Button type="button" variant="glass" onClick={locate} disabled={isLocating || !stations.length}>
|
||||||
|
{isLocating ? <LoaderCircle className="size-4 animate-spin" /> : <LocateFixed className="size-4" />}
|
||||||
|
{isLocating ? t("location.gpsLocating") : t("location.gpsUse")}
|
||||||
|
</Button>
|
||||||
|
{message && <p aria-live="polite" className="max-w-xl text-xs leading-5 text-slate-600 dark:text-slate-300">{message}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
||||||
|
{t("location.gpsAttribution")} <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">OpenStreetMap <ExternalLink className="size-3" /></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useWeatherStore } from "@/lib/store";
|
|||||||
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
||||||
|
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
||||||
|
|
||||||
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
@@ -44,6 +45,7 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
|
|||||||
/>
|
/>
|
||||||
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
|
{query && <button type="button" aria-label={t("location.clear")} onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-500 transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"><X className="size-4" /></button>}
|
||||||
</label>
|
</label>
|
||||||
|
<CurrentLocationControl stations={locatedStations} />
|
||||||
{selectedLocation && (
|
{selectedLocation && (
|
||||||
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
|
<p className="mt-3 px-1 text-xs text-slate-600 dark:text-slate-300">
|
||||||
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
{t("location.currentSource", { location: selectedLocation.name, station: selectedLocation.stationName, distance: selectedLocation.distanceKm })}
|
||||||
|
|||||||
30
lib/i18n.tsx
30
lib/i18n.tsx
@@ -36,6 +36,21 @@ const translations = {
|
|||||||
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
|
"location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
|
||||||
"location.heroSource": "stacja IMGW: {station} · około {distance} km",
|
"location.heroSource": "stacja IMGW: {station} · około {distance} km",
|
||||||
"location.attribution": "Wyszukiwanie miejscowości:",
|
"location.attribution": "Wyszukiwanie miejscowości:",
|
||||||
|
"location.gpsUse": "Użyj mojej lokalizacji",
|
||||||
|
"location.gpsLocating": "Ustalam lokalizację…",
|
||||||
|
"location.gpsPromptTitle": "Pokazać pogodę dla Twojej lokalizacji?",
|
||||||
|
"location.gpsPromptDescription": "Po Twojej zgodzie wtr. użyje GPS, aby wybrać miejscowość, najbliższą stację IMGW i prognozę. Pozycja zostanie zaokrąglona do około 100 metrów.",
|
||||||
|
"location.gpsAllow": "Użyj GPS",
|
||||||
|
"location.gpsNotNow": "Nie teraz",
|
||||||
|
"location.gpsSecureContext": "GPS wymaga HTTPS. Na iPhonie lokalny adres HTTP z adresem IP nie może wyświetlić systemowego pytania o położenie. Użyj wdrożonej wersji HTTPS.",
|
||||||
|
"location.gpsUnavailable": "Ta przeglądarka nie udostępnia lokalizacji GPS.",
|
||||||
|
"location.gpsDenied": "Dostęp do lokalizacji został odrzucony. Możesz zmienić uprawnienia witryny w ustawieniach przeglądarki.",
|
||||||
|
"location.gpsTimeout": "Nie udało się ustalić lokalizacji w wymaganym czasie. Spróbuj ponownie.",
|
||||||
|
"location.gpsPositionUnavailable": "Nie udało się ustalić lokalizacji GPS. Sprawdź ustawienia urządzenia i spróbuj ponownie.",
|
||||||
|
"location.gpsStationsPending": "Lista stacji IMGW nie jest jeszcze gotowa. Spróbuj ponownie za chwilę.",
|
||||||
|
"location.gpsFallbackName": "Bieżąca lokalizacja",
|
||||||
|
"location.gpsSelected": "Wybrano lokalizację: {location}.",
|
||||||
|
"location.gpsAttribution": "Nazwy miejsc dla GPS:",
|
||||||
"featured.label": "Szybki wybór",
|
"featured.label": "Szybki wybór",
|
||||||
"featured.title": "Wybrane stacje IMGW",
|
"featured.title": "Wybrane stacje IMGW",
|
||||||
"favorites.title": "Ulubione lokalizacje",
|
"favorites.title": "Ulubione lokalizacje",
|
||||||
@@ -165,6 +180,21 @@ const translations = {
|
|||||||
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
|
"location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
|
||||||
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
|
"location.heroSource": "IMGW station: {station} · approximately {distance} km",
|
||||||
"location.attribution": "Place search:",
|
"location.attribution": "Place search:",
|
||||||
|
"location.gpsUse": "Use my location",
|
||||||
|
"location.gpsLocating": "Finding your location…",
|
||||||
|
"location.gpsPromptTitle": "Show weather for your location?",
|
||||||
|
"location.gpsPromptDescription": "With your permission, wtr. will use GPS to select your place, the nearest IMGW station and the forecast. Your position will be rounded to approximately 100 metres.",
|
||||||
|
"location.gpsAllow": "Use GPS",
|
||||||
|
"location.gpsNotNow": "Not now",
|
||||||
|
"location.gpsSecureContext": "GPS requires HTTPS. On iPhone, a local HTTP address using an IP cannot display the system location prompt. Use the deployed HTTPS version.",
|
||||||
|
"location.gpsUnavailable": "This browser does not provide GPS location access.",
|
||||||
|
"location.gpsDenied": "Location access was denied. You can change the website permission in your browser settings.",
|
||||||
|
"location.gpsTimeout": "Your location could not be determined in time. Try again.",
|
||||||
|
"location.gpsPositionUnavailable": "Your GPS location could not be determined. Check your device settings and try again.",
|
||||||
|
"location.gpsStationsPending": "The IMGW station list is not ready yet. Try again in a moment.",
|
||||||
|
"location.gpsFallbackName": "Current location",
|
||||||
|
"location.gpsSelected": "Selected location: {location}.",
|
||||||
|
"location.gpsAttribution": "GPS place names:",
|
||||||
"featured.label": "Quick select",
|
"featured.label": "Quick select",
|
||||||
"featured.title": "Selected IMGW stations",
|
"featured.title": "Selected IMGW stations",
|
||||||
"favorites.title": "Favourite locations",
|
"favorites.title": "Favourite locations",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Language } from "@/lib/i18n";
|
import type { Language } from "@/lib/i18n";
|
||||||
import type { LocationSearchResult } from "@/types/location";
|
import type { LocationSearchResult, ReverseLocationResult } from "@/types/location";
|
||||||
|
|
||||||
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
|
export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise<LocationSearchResult[]> {
|
||||||
const params = new URLSearchParams({ query, language });
|
const params = new URLSearchParams({ query, language });
|
||||||
@@ -7,3 +7,10 @@ export async function fetchLocations(query: string, language: Language, signal?:
|
|||||||
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
|
if (!response.ok) throw new Error("Location search is temporarily unavailable.");
|
||||||
return response.json() as Promise<LocationSearchResult[]>;
|
return response.json() as Promise<LocationSearchResult[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchReverseLocation(latitude: number, longitude: number, language: Language): Promise<ReverseLocationResult> {
|
||||||
|
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), language });
|
||||||
|
const response = await fetch(`/api/locations/reverse?${params}`);
|
||||||
|
if (!response.ok) throw new Error("Reverse location search is temporarily unavailable.");
|
||||||
|
return response.json() as Promise<ReverseLocationResult>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export function locateSynopStations(stations: SynopStation[], positions: MeteoSt
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findNearestSynopStation(location: LocationSearchResult, stations: LocatedSynopStation[]): SelectedLocation | null {
|
export function findNearestSynopStation(
|
||||||
|
location: Pick<LocationSearchResult, "name" | "province" | "latitude" | "longitude">,
|
||||||
|
stations: LocatedSynopStation[],
|
||||||
|
): SelectedLocation | null {
|
||||||
const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
|
const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
|
||||||
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
|
const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
|
||||||
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
|
return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface LocationSearchResult {
|
|||||||
district: string | null;
|
district: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReverseLocationResult {
|
||||||
|
name: string;
|
||||||
|
province: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SelectedLocation {
|
export interface SelectedLocation {
|
||||||
name: string;
|
name: string;
|
||||||
province: string | null;
|
province: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user