Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 37 additions & 43 deletions src/admin/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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] : '';
};
Comment on lines +96 to +100

Copilot AI Sep 27, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manual path parsing with regex replaces SolidJS Router functionality without clear justification. This reduces maintainability and removes built-in router features like parameter validation and navigation guards.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

somehow without this it didn't work, and claude fixed it like this. i'll test again organically 😎

const model = () => models.find(m => m.key === modelKey());

return (
<Container>
Expand Down Expand Up @@ -135,42 +136,35 @@ export default function Admin() {
);
}

return (
<>
<Router>
<Route
path="/login"
element={
<form
onSubmit={login}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translateY(-50%) translateX(-50%)'
}}
>
<Input
type="password"
label="Password"
autoComplete="current-password"
/>
&nbsp;
<Button type="submit" color="primary">
Log in
</Button>
</form>
}
const currentPath = location.pathname;

if (currentPath === '/admin/login' || currentPath === '/admin/') {
return (
<form
onSubmit={login}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translateY(-50%) translateX(-50%)'
}}
>
<Input
type="password"
label="Password"
autoComplete="current-password"
/>
<Route path="/model/:model" component={Model} />
</Router>
{/* <Snackbar
autoHideDuration={4000}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
onClose={this.clearMessage}
message={<span>{message}</span>}
open={messageVisible}
/> */}
</>
);
&nbsp;
<Button type="submit" color="primary">
Log in
</Button>
</form>
);
}

if (currentPath.startsWith('/admin/model/')) {
return <Model />;
Comment on lines +139 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make admin view react to router path changes

Caching location.pathname in const currentPath = location.pathname; removes the reactivity of the router. After a user logs in and navigate('/admin/model/areas') runs, this component continues to render the login form because currentPath never updates, leaving the UI out of sync with the URL. Use the reactive location.pathname directly inside the conditional rendering so that navigating within /admin re-renders the appropriate view.

Useful? React with 👍 / 👎.

}

return <div>Admin route not found</div>;
}
23 changes: 19 additions & 4 deletions src/admin/inputs.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -83,13 +88,20 @@ const MenuUrlInput = (props: InputProps) => {
);
};

function AddressInput(props: InputProps) {
function AddressInput(props: AddressInputProps) {
return (
<Input
onChange={value => props.setValue(props.field.path, value)}
<AddressSearchInput
value={props.value || ''}
label={props.field.title}
type="text"
onChange={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);
Comment on lines +102 to +103

Copilot AI Sep 27, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded field names 'latitude' and 'longitude' should be configurable or derived from the field structure to maintain consistency with the model definition.

Copilot uses AI. Check for mistakes.
}}
/>
);
}
Expand Down Expand Up @@ -166,6 +178,9 @@ const LocationInput = (props: GroupInputProps) => {
props.setValue('longitude', v[1]);
}
}}
onAddressChange={(address: string) => {
props.setValue('address', address);
}}
/>
);
};
Expand Down
195 changes: 195 additions & 0 deletions src/components/AddressSearchInput.tsx
Original file line number Diff line number Diff line change
@@ -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<NominatimResult[]>([]);
const [loading, setLoading] = createSignal(false);
const [showResults, setShowResults] = createSignal(false);
let searchTimeout: ReturnType<typeof setTimeout>;


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`

Copilot AI Sep 27, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded country code 'fi' (Finland) should be configurable to make the component reusable across different regions.

Copilot uses AI. Check for mistakes.
);

if (response.ok) {
const data: NominatimResult[] = await response.json();

// Deduplicate results based on formatted address
const uniqueResults: NominatimResult[] = [];
const seenAddresses = new Set<string>();

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);
};
Comment on lines +148 to +153

Copilot AI Sep 27, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setTimeout callback is not stored or cleared, which could cause the results to hide even after the component unmounts or after user re-focuses. Store the timeout reference and clear it when needed.

Copilot uses AI. Check for mistakes.

createEffect(() => {
if (props.value !== searchQuery()) {
setSearchQuery(props.value);
}
});

return (
<Container>
<div onBlur={handleBlur}>
<Input
label={props.label}
value={searchQuery()}
onChange={handleInputChange}
type="text"
/>
</div>

<Show when={showResults() && (results().length > 0 || loading())}>
<ResultsList>
<Show when={loading()}>
<LoadingMessage>Searching...</LoadingMessage>
</Show>

<For each={results()}>
{(result) => (
<ResultItem onClick={() => selectResult(result)}>
{formatAddress(result)}
</ResultItem>
)}
</For>

<Show when={!loading() && results().length === 0}>
<LoadingMessage>No results found</LoadingMessage>
</Show>
</ResultsList>
</Show>
</Container>
);
};

export default AddressSearchInput;
23 changes: 23 additions & 0 deletions src/components/LatLngInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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);
});
});

Expand Down
Loading
Loading