160 lines
7.3 KiB
TypeScript
160 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, type KeyboardEvent } from "react";
|
|
import { LoaderCircle, MapPin, Search, X } from "lucide-react";
|
|
import { CurrentLocationControl } from "@/components/weather/current-location-control";
|
|
import { useLocationSearch } from "@/hooks/use-location-search";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { createSelectedLocation, locateSynopStations } from "@/lib/location-utils";
|
|
import { useWeatherStore } from "@/lib/store";
|
|
import { formatDistance } from "@/lib/weather-utils";
|
|
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
|
|
import type { SelectedLocation } from "@/types/location";
|
|
|
|
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 distanceUnit = useWeatherStore((state) => state.distanceUnit);
|
|
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,
|
|
selected: createSelectedLocation(location, locatedStations),
|
|
})),
|
|
[locatedStations, locations],
|
|
);
|
|
const showSuggestions = isFocused && query.trim().length >= 2;
|
|
const isPreparingStations =
|
|
positions.length === 0 && suggestions.some(({ selected }) => selected.region === "PL" && !selected.stationName);
|
|
|
|
function chooseLocation(location: SelectedLocation) {
|
|
selectLocation(location);
|
|
setQuery("");
|
|
setIsFocused(false);
|
|
}
|
|
|
|
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
|
const firstSuggestion = suggestions[0]?.selected;
|
|
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
|
|
event.preventDefault();
|
|
chooseLocation(firstSuggestion);
|
|
}
|
|
|
|
return (
|
|
<section className="relative z-30">
|
|
<div className="glass rounded-panel p-3 sm:p-4">
|
|
<div className="flex items-center gap-2 px-1 pb-3">
|
|
<MapPin className="size-4 text-accent" />
|
|
<p className="section-kicker">{t("location.label")}</p>
|
|
</div>
|
|
<div className="relative z-20">
|
|
<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-accent" />
|
|
) : (
|
|
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted" />
|
|
)}
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
|
|
placeholder={t("location.placeholder")}
|
|
autoComplete="off"
|
|
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-base placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent sm:text-sm"
|
|
/>
|
|
{query && (
|
|
<button
|
|
type="button"
|
|
aria-label={t("location.clear")}
|
|
onClick={() => setQuery("")}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-control p-1 text-muted transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
)}
|
|
</label>
|
|
{showSuggestions && (
|
|
<div className="glass absolute inset-x-0 top-[calc(100%+0.5rem)] overflow-hidden rounded-panel p-2 shadow-card">
|
|
{isPreparingStations ? (
|
|
<p className="px-3 py-4 text-sm text-muted">{t("location.preparing")}</p>
|
|
) : isError ? (
|
|
<p className="px-3 py-4 text-sm text-muted">{t("location.error")}</p>
|
|
) : !isFetching && !suggestions.length ? (
|
|
<p className="px-3 py-4 text-sm text-muted">{t("location.empty")}</p>
|
|
) : (
|
|
suggestions.map(({ location, selected }) => (
|
|
<button
|
|
type="button"
|
|
key={location.id}
|
|
onClick={() => chooseLocation(selected)}
|
|
className="flex w-full items-start justify-between gap-3 rounded-card px-3 py-3 text-left transition hover:bg-surface-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
>
|
|
<span>
|
|
<span className="block text-sm font-semibold">{location.name}</span>
|
|
<span className="mt-0.5 block text-xs text-muted">
|
|
{[location.admin2, location.admin1, location.country].filter(Boolean).join(" · ")}
|
|
</span>
|
|
</span>
|
|
{selected.stationName && selected.distanceKm !== null ? (
|
|
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
|
|
{t("location.nearest")}
|
|
<br />
|
|
<strong className="font-semibold text-foreground">
|
|
{selected.stationName} · {formatDistance(selected.distanceKm, language, distanceUnit)}
|
|
</strong>
|
|
</span>
|
|
) : (
|
|
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">
|
|
{t("location.modelSource")}
|
|
<br />
|
|
<strong className="font-semibold text-foreground">Open-Meteo</strong>
|
|
</span>
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<CurrentLocationControl stations={locatedStations} />
|
|
{selectedLocation && (
|
|
<p className="mt-3 px-1 text-xs text-muted">
|
|
{selectedLocation.stationName && selectedLocation.distanceKm !== null
|
|
? t("location.currentSource", {
|
|
location: selectedLocation.name,
|
|
station: selectedLocation.stationName,
|
|
distance: formatDistance(selectedLocation.distanceKm, language, distanceUnit),
|
|
})
|
|
: t("location.currentSourceGlobal", { location: selectedLocation.name })}
|
|
</p>
|
|
)}
|
|
<p className="mt-3 px-1 text-[0.68rem] text-muted">
|
|
{t("location.attribution")}{" "}
|
|
<a
|
|
href="https://open-meteo.com/en/docs/geocoding-api"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline decoration-muted/60 underline-offset-2 transition hover:text-accent"
|
|
>
|
|
Open-Meteo / GeoNames
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|