Files
wtr/components/weather/location-search.tsx
zv 91acdb39b8
Some checks failed
CI / Lint, test, typecheck and build (push) Has been cancelled
feat: add weather unit preferences
2026-07-04 20:16:11 +02:00

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