86 lines
5.6 KiB
TypeScript
86 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
|
|
import { useLocationSearch } from "@/hooks/use-location-search";
|
|
import { useWeatherStore } from "@/lib/store";
|
|
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
|
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
|
|
|
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
|
|
const { language, t } = useI18n();
|
|
const [query, setQuery] = useState("");
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const selectedLocation = useWeatherStore((state) => state.selectedLocation);
|
|
const selectLocation = useWeatherStore((state) => state.selectLocation);
|
|
const { data: locations, isFetching, isError } = useLocationSearch(query, language);
|
|
const locatedStations = useMemo(() => locateSynopStations(stations, positions), [positions, stations]);
|
|
const suggestions = useMemo(() => (locations ?? []).map((location) => ({
|
|
location,
|
|
nearest: findNearestSynopStation(location, locatedStations),
|
|
})).filter((suggestion) => suggestion.nearest !== null), [locatedStations, locations]);
|
|
const showSuggestions = isFocused && query.trim().length >= 2;
|
|
const isPreparingStations = positions.length === 0;
|
|
|
|
return (
|
|
<section className="relative z-30">
|
|
<div className="glass rounded-[1.75rem] p-3 sm:p-4">
|
|
<div className="flex items-center gap-2 px-1 pb-3">
|
|
<MapPin className="size-4 text-sky-700 dark:text-sky-300" />
|
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300">{t("location.label")}</p>
|
|
</div>
|
|
<label className="relative block">
|
|
<span className="sr-only">{t("location.searchLabel")}</span>
|
|
{isFetching || isPreparingStations ? <LoaderCircle className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 animate-spin text-sky-600" /> : <Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-500" />}
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
|
placeholder={t("location.placeholder")}
|
|
autoComplete="off"
|
|
className="w-full rounded-2xl border border-white/40 bg-white/55 py-3.5 pl-10 pr-10 text-sm placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-white/10 dark:bg-white/10"
|
|
/>
|
|
{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>
|
|
<CurrentLocationControl stations={locatedStations} />
|
|
{selectedLocation && (
|
|
<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 })}
|
|
</p>
|
|
)}
|
|
<p className="mt-3 px-1 text-[0.68rem] text-slate-500 dark:text-slate-400">
|
|
{t("location.attribution")} <a href="https://open-meteo.com/en/docs/geocoding-api" target="_blank" rel="noreferrer" className="underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo / GeoNames</a>
|
|
</p>
|
|
</div>
|
|
{showSuggestions && (
|
|
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-[1.5rem] p-2 shadow-glass">
|
|
{isPreparingStations ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.preparing")}</p> :
|
|
isError ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.error")}</p> :
|
|
!isFetching && !suggestions.length ? <p className="px-3 py-4 text-sm text-slate-600 dark:text-slate-300">{t("location.empty")}</p> :
|
|
suggestions.map(({ location, nearest }) => nearest && (
|
|
<button
|
|
type="button"
|
|
key={location.id}
|
|
onClick={() => {
|
|
selectLocation(nearest);
|
|
setQuery("");
|
|
setIsFocused(false);
|
|
}}
|
|
className="flex w-full items-start justify-between gap-3 rounded-2xl px-3 py-3 text-left transition hover:bg-white/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:hover:bg-white/10"
|
|
>
|
|
<span>
|
|
<span className="block text-sm font-semibold">{location.name}</span>
|
|
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{[location.district, location.province].filter(Boolean).join(" · ")}</span>
|
|
</span>
|
|
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-slate-500 dark:text-slate-400">{t("location.nearest")}<br /><strong className="font-semibold text-slate-700 dark:text-slate-200">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|