Lots of improvements
Some checks failed
CI / Python (lint + test) (push) Failing after 1m39s
CI / Frontend (lint + typecheck) (push) Failing after 1m49s
CI / Rust (lint + test) (push) Failing after 1m50s
Build and publish Docker image / build-and-push (push) Failing after 3m9s

This commit is contained in:
Andras Schmelczer 2026-04-04 10:45:48 +01:00
parent 3853b5dce7
commit b94cf17d75
33 changed files with 2587 additions and 1866 deletions

View file

@ -1,9 +1,11 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { PostcodeGeometry } from '../../types';
import { authHeaders } from '../../lib/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { LocateIcon } from '../ui/icons/LocateIcon';
import { SearchIcon } from '../ui/icons/SearchIcon';
export interface SearchedLocation {
@ -14,6 +16,7 @@ export interface SearchedLocation {
const ZOOM_FOR_TYPE: Record<string, number> = {
city: 10,
borough: 12,
outcode: 14,
town: 13,
suburb: 14,
quarter: 14,
@ -35,6 +38,7 @@ export default function LocationSearch({
onLocationSearched?: (postcode: SearchedLocation | null) => void;
onMouseEnter?: () => void;
}) {
const { t } = useTranslation();
const search = useLocationSearch();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -80,7 +84,7 @@ export default function LocationSearch({
try {
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
if (!res.ok) {
setError('Postcode not found');
setError(t('locationSearch.postcodeNotFound'));
return;
}
const json: {
@ -94,7 +98,7 @@ export default function LocationSearch({
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError('Lookup failed');
setError(t('locationSearch.lookupFailed'));
} finally {
setLoading(false);
}
@ -102,17 +106,71 @@ export default function LocationSearch({
[onFlyTo, onLocationSearched, isMobile, search]
);
// Mobile collapsed state: just a search icon button
const [locating, setLocating] = useState(false);
const locateUser = useCallback(async () => {
if (!navigator.geolocation) {
setError(t('locationSearch.geolocationUnsupported'));
return;
}
setError(null);
setLocating(true);
search.close();
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
});
});
const { latitude, longitude } = position.coords;
const res = await fetch(
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
authHeaders()
);
if (!res.ok) {
setError(t('locationSearch.lookupFailed'));
return;
}
const json: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
} = await res.json();
onFlyTo(json.latitude, json.longitude, 16);
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
search.clear();
if (isMobile) setExpanded(false);
} catch {
setError(t('locationSearch.geolocationFailed'));
} finally {
setLocating(false);
}
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
// Mobile collapsed state: search icon + locate button
if (isMobile && !expanded) {
return (
<button
type="button"
onClick={() => setExpanded(true)}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg pointer-events-auto"
aria-label="Search places or postcodes"
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
<div className="flex gap-2 pointer-events-auto">
<button
type="button"
onClick={() => setExpanded(true)}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
aria-label={t('locationSearch.searchLabel')}
>
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
</button>
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg text-warm-600 dark:text-warm-300 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-5 h-5 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
);
}
@ -129,12 +187,22 @@ export default function LocationSearch({
search={search}
onSelect={selectResult}
loading={loading}
placeholder="Search places or postcodes..."
placeholder={t('locationSearch.placeholder')}
size="sm"
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
inputRef={inputRef}
onInputChange={() => setError(null)}
/>
<button
type="button"
onClick={locateUser}
disabled={locating}
className="p-2 mr-0.5 rounded hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-400 dark:text-warm-500 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
aria-label={t('locationSearch.locateMe')}
title={t('locationSearch.locateMe')}
>
<LocateIcon className={`w-4 h-4 ${locating ? 'animate-pulse' : ''}`} />
</button>
</div>
{error && (