Compare commits

..

3 Commits

Author SHA1 Message Date
zv
4b28e281c1 chore: update next type references
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m55s
2026-06-14 10:49:09 +02:00
zv
de06056596 feat: select first location suggestion on enter 2026-06-14 10:26:40 +02:00
zv
89d2066077 fix: position location suggestions under search 2026-06-14 10:25:19 +02:00
2 changed files with 54 additions and 41 deletions

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState, type KeyboardEvent } from "react";
import { LoaderCircle, MapPin, Search, X } from "lucide-react"; import { LoaderCircle, MapPin, Search, X } from "lucide-react";
import { useLocationSearch } from "@/hooks/use-location-search"; import { useLocationSearch } from "@/hooks/use-location-search";
import { useWeatherStore } from "@/lib/store"; import { useWeatherStore } from "@/lib/store";
import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils"; import { findNearestSynopStation, locateSynopStations } from "@/lib/location-utils";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { MeteoStationPosition, SynopStation } from "@/types/imgw"; import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { SelectedLocation } from "@/types/location";
import { CurrentLocationControl } from "@/components/weather/current-location-control"; import { CurrentLocationControl } from "@/components/weather/current-location-control";
export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) { export function LocationSearch({ stations, positions }: { stations: SynopStation[]; positions: MeteoStationPosition[] }) {
@@ -24,6 +25,19 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
const showSuggestions = isFocused && query.trim().length >= 2; const showSuggestions = isFocused && query.trim().length >= 2;
const isPreparingStations = positions.length === 0; const isPreparingStations = positions.length === 0;
function chooseLocation(location: SelectedLocation) {
selectLocation(location);
setQuery("");
setIsFocused(false);
}
function handleSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
const firstSuggestion = suggestions[0]?.nearest;
if (event.key !== "Enter" || !showSuggestions || !firstSuggestion) return;
event.preventDefault();
chooseLocation(firstSuggestion);
}
return ( return (
<section className="relative z-30"> <section className="relative z-30">
<div className="glass rounded-panel p-3 sm:p-4"> <div className="glass rounded-panel p-3 sm:p-4">
@@ -31,20 +45,44 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
<MapPin className="size-4 text-accent" /> <MapPin className="size-4 text-accent" />
<p className="section-kicker">{t("location.label")}</p> <p className="section-kicker">{t("location.label")}</p>
</div> </div>
<label className="relative block"> <div className="relative z-20">
<span className="sr-only">{t("location.searchLabel")}</span> <label className="relative block">
{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" />} <span className="sr-only">{t("location.searchLabel")}</span>
<input {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" />}
value={query} <input
onChange={(event) => setQuery(event.target.value)} value={query}
onFocus={() => setIsFocused(true)} onChange={(event) => setQuery(event.target.value)}
onBlur={() => window.setTimeout(() => setIsFocused(false), 150)} onKeyDown={handleSearchKeyDown}
placeholder={t("location.placeholder")} onFocus={() => setIsFocused(true)}
autoComplete="off" onBlur={() => window.setTimeout(() => setIsFocused(false), 150)}
className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" placeholder={t("location.placeholder")}
/> autoComplete="off"
{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>} className="w-full rounded-card border border-border/70 bg-surface-raised/80 py-3.5 pl-10 pr-10 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
</label> />
{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, nearest }) => nearest && (
<button
type="button"
key={location.id}
onClick={() => chooseLocation(nearest)}
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.district, location.province].filter(Boolean).join(" · ")}</span>
</span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
</button>
))}
</div>
)}
</div>
<CurrentLocationControl stations={locatedStations} /> <CurrentLocationControl stations={locatedStations} />
{selectedLocation && ( {selectedLocation && (
<p className="mt-3 px-1 text-xs text-muted"> <p className="mt-3 px-1 text-xs text-muted">
@@ -55,31 +93,6 @@ export function LocationSearch({ stations, positions }: { stations: SynopStation
{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> {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> </p>
</div> </div>
{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, 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-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.district, location.province].filter(Boolean).join(" · ")}</span>
</span>
<span className="shrink-0 text-right text-[0.68rem] leading-5 text-muted">{t("location.nearest")}<br /><strong className="font-semibold text-foreground">{nearest.stationName} · {nearest.distanceKm} km</strong></span>
</button>
))}
</div>
)}
</section> </section>
); );
} }

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.