136 lines
5.9 KiB
TypeScript
136 lines
5.9 KiB
TypeScript
"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>
|
|
);
|
|
}
|