Files
wtr/components/weather/current-location-control.tsx

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>
);
}