diff --git a/src/admin/index.tsx b/src/admin/index.tsx index 4719498..5ff0327 100644 --- a/src/admin/index.tsx +++ b/src/admin/index.tsx @@ -1,9 +1,6 @@ import { - Route, - Router, useLocation, - useNavigate, - useParams + useNavigate } from '@solidjs/router'; import { onMount, Show, For } from 'solid-js'; import { createStore } from 'solid-js/store'; @@ -96,8 +93,12 @@ export default function Admin() { }); function Model() { - const params = useParams(); - const model = () => models.find(m => m.key === params.model); + const modelKey = () => { + const path = location.pathname; + const match = path.match(/\/admin\/model\/(.+)/); + return match ? match[1] : ''; + }; + const model = () => models.find(m => m.key === modelKey()); return ( @@ -135,42 +136,35 @@ export default function Admin() { ); } - return ( - <> - - - -   - - - } + const currentPath = location.pathname; + + if (currentPath === '/admin/login' || currentPath === '/admin/') { + return ( +
+ - - - {/* {message}} - open={messageVisible} - /> */} - - ); +   + + + ); + } + + if (currentPath.startsWith('/admin/model/')) { + return ; + } + + return
Admin route not found
; } diff --git a/src/admin/inputs.tsx b/src/admin/inputs.tsx index 68024a3..7ae9e94 100644 --- a/src/admin/inputs.tsx +++ b/src/admin/inputs.tsx @@ -1,6 +1,7 @@ import { createResource, createSignal, For, Show } from 'solid-js'; import LatLngInput from '../components/LatLngInput'; import OpeningHoursInput from '../components/OpeningHoursInput'; +import AddressSearchInput from '../components/AddressSearchInput'; import * as api from './api'; import models from './models'; import { @@ -28,6 +29,10 @@ interface GroupInputProps { setValue(path: string, value: any): any; } +interface AddressInputProps extends InputProps { + onCoordinatesChange?: (lat: number, lng: number) => void; +} + const Row = styled.div` display: flex; gap: 1rem; @@ -83,13 +88,20 @@ const MenuUrlInput = (props: InputProps) => { ); }; -function AddressInput(props: InputProps) { +function AddressInput(props: AddressInputProps) { return ( - props.setValue(props.field.path, value)} + props.setValue(props.field.path, value)} + onCoordinatesChange={(lat, lng) => { + if (props.onCoordinatesChange) { + props.onCoordinatesChange(lat, lng); + } + // Update coordinates directly if available + props.setValue('latitude', lat); + props.setValue('longitude', lng); + }} /> ); } @@ -166,6 +178,9 @@ const LocationInput = (props: GroupInputProps) => { props.setValue('longitude', v[1]); } }} + onAddressChange={(address: string) => { + props.setValue('address', address); + }} /> ); }; diff --git a/src/components/AddressSearchInput.tsx b/src/components/AddressSearchInput.tsx new file mode 100644 index 0000000..9698d38 --- /dev/null +++ b/src/components/AddressSearchInput.tsx @@ -0,0 +1,195 @@ +import { createEffect, createSignal, For, Show } from 'solid-js'; +import { styled } from 'solid-styled-components'; +import Input from './Input'; +import { formatAddress, type AddressData } from '../utils/addressFormatter'; + +interface NominatimResult extends AddressData { + place_id: number; + licence: string; + osm_type: string; + osm_id: number; + boundingbox: string[]; + lat: string; + lon: string; + class: string; + type: string; + importance: number; +} + +interface Props { + value: string; + label: string; + onChange: (address: string) => void; + onCoordinatesChange?: (lat: number, lng: number) => void; + placeholder?: string; +} + +const Container = styled.div` + position: relative; +`; + +const ResultsList = styled.ul` + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ccc; + border-top: none; + border-radius: 0 0 4px 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + margin: 0; + padding: 0; + list-style: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +`; + +const ResultItem = styled.li` + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #eee; + + &:hover { + background-color: #f5f5f5; + } + + &:last-child { + border-bottom: none; + } +`; + +const LoadingMessage = styled.div` + padding: 8px 12px; + color: #666; + font-style: italic; +`; + +const AddressSearchInput = (props: Props) => { + const [searchQuery, setSearchQuery] = createSignal(props.value); + const [results, setResults] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [showResults, setShowResults] = createSignal(false); + let searchTimeout: ReturnType; + + + const searchAddresses = async (query: string) => { + if (query.length < 3) { + setResults([]); + setShowResults(false); + return; + } + + setLoading(true); + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&addressdetails=1&countrycodes=fi` + ); + + if (response.ok) { + const data: NominatimResult[] = await response.json(); + + // Deduplicate results based on formatted address + const uniqueResults: NominatimResult[] = []; + const seenAddresses = new Set(); + + for (const result of data) { + const formattedAddr = formatAddress(result); + if (!seenAddresses.has(formattedAddr)) { + seenAddresses.add(formattedAddr); + uniqueResults.push(result); + } + } + + setResults(uniqueResults); + setShowResults(true); + } else { + console.error('Geocoding request failed:', response.statusText); + setResults([]); + setShowResults(false); + } + } catch (error) { + console.error('Geocoding error:', error); + setResults([]); + setShowResults(false); + } finally { + setLoading(false); + } + }; + + const debouncedSearch = (query: string) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + searchAddresses(query); + }, 300); + }; + + const handleInputChange = (value: string) => { + setSearchQuery(value); + props.onChange(value); + debouncedSearch(value); + }; + + const selectResult = (result: NominatimResult) => { + const address = formatAddress(result); + setSearchQuery(address); + props.onChange(address); + + if (props.onCoordinatesChange) { + props.onCoordinatesChange(parseFloat(result.lat), parseFloat(result.lon)); + } + + setShowResults(false); + setResults([]); + }; + + const handleBlur = () => { + // Delay hiding results to allow click events + setTimeout(() => { + setShowResults(false); + }, 200); + }; + + createEffect(() => { + if (props.value !== searchQuery()) { + setSearchQuery(props.value); + } + }); + + return ( + +
+ +
+ + 0 || loading())}> + + + Searching... + + + + {(result) => ( + selectResult(result)}> + {formatAddress(result)} + + )} + + + + No results found + + + +
+ ); +}; + +export default AddressSearchInput; \ No newline at end of file diff --git a/src/components/LatLngInput.tsx b/src/components/LatLngInput.tsx index 26a9c0d..81e1ad0 100644 --- a/src/components/LatLngInput.tsx +++ b/src/components/LatLngInput.tsx @@ -3,11 +3,13 @@ import { styled } from 'solid-styled-components'; import Input from './Input'; import leaflet from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { formatAddress } from '../utils/addressFormatter'; interface Props { disabled?: boolean; value: [number, number]; onChange(latLng: [number, number]): void; + onAddressChange?: (address: string) => void; } const LatLngContainer = styled.div` @@ -44,6 +46,26 @@ const LatLngInput = (props: Props) => { let marker: leaflet.Marker; let map: leaflet.Map; + const reverseGeocode = async (lat: number, lng: number) => { + if (!props.onAddressChange) return; + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=18&addressdetails=1` + ); + + if (response.ok) { + const data = await response.json(); + if (data.display_name) { + const formattedAddress = formatAddress(data); + props.onAddressChange(formattedAddress); + } + } + } catch (error) { + console.error('Reverse geocoding error:', error); + } + }; + onMount(() => { map = leaflet.map(container!).setView(props.value, 14); leaflet @@ -57,6 +79,7 @@ const LatLngInput = (props: Props) => { marker.addEventListener('dragend', () => { const pos = marker.getLatLng(); props.onChange([pos.lat, pos.lng]); + reverseGeocode(pos.lat, pos.lng); }); }); diff --git a/src/utils/addressFormatter.ts b/src/utils/addressFormatter.ts new file mode 100644 index 0000000..60ccb3a --- /dev/null +++ b/src/utils/addressFormatter.ts @@ -0,0 +1,41 @@ +export interface AddressData { + display_name: string; + address?: { + house_number?: string; + road?: string; + postcode?: string; + city?: string; + town?: string; + municipality?: string; + suburb?: string; + neighbourhood?: string; + }; +} + +export const formatAddress = (data: AddressData): string => { + if (!data.address) { + return data.display_name; + } + + const addr = data.address; + const parts: string[] = []; + + // Street and house number + if (addr.road) { + if (addr.house_number) { + parts.push(`${addr.road} ${addr.house_number}`); + } else { + parts.push(addr.road); + } + } + + // Postal code and city + const cityName = addr.city || addr.town || addr.municipality || 'Helsinki'; + if (addr.postcode && cityName) { + parts.push(`${addr.postcode} ${cityName}`); + } else if (cityName) { + parts.push(cityName); + } + + return parts.length > 0 ? parts.join(', ') : data.display_name; +}; \ No newline at end of file