feat: add OpenStreetMap location autocomplete
This commit is contained in:
parent
c120a0ad7f
commit
8c82db037a
@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
## [1.116.0] - 2026-09-04
|
||||
|
||||
### Added
|
||||
- Added a two-day reminder preset for calendar events.
|
||||
- Added optional address and business autocomplete using Photon/OpenStreetMap,
|
||||
with debounced server-side search and OpenStreetMap attribution.
|
||||
- Users can keep their original location text or explicitly select a suggested result.
|
||||
|
||||
## [1.115.3] - 2026-09-03
|
||||
|
||||
### Fixed
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.115.3",
|
||||
"version": "1.116.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.115.3",
|
||||
"version": "1.116.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.1",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.115.3",
|
||||
"version": "1.116.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
59
src/app/api/places/autocomplete/route.ts
Normal file
59
src/app/api/places/autocomplete/route.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Server-side proxy for Photon, an OpenStreetMap-based geocoder. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 401 });
|
||||
}
|
||||
|
||||
const input = request.nextUrl.searchParams.get('input')?.trim() || '';
|
||||
const languageCode = (request.nextUrl.searchParams.get('languageCode') || 'en').split('-')[0].slice(0, 5);
|
||||
|
||||
if (input.length < 2) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
q: input,
|
||||
limit: '5',
|
||||
lang: languageCode,
|
||||
});
|
||||
const response = await fetch(`https://photon.komoot.io/api/?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'My-Weekly-ToDo-List/1.0 (location autocomplete)',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[PLACES] Autocomplete request failed:', response.status);
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const suggestions = (data.features || [])
|
||||
.map((feature: any) => {
|
||||
const properties = feature.properties || {};
|
||||
const streetAddress = [properties.street, properties.housenumber].filter(Boolean).join(' ');
|
||||
const locality = [properties.postcode, properties.city || properties.locality].filter(Boolean).join(' ');
|
||||
const label = [properties.name, streetAddress, locality, properties.country]
|
||||
.filter(Boolean)
|
||||
.filter((part: string, index: number, parts: string[]) => parts.indexOf(part) === index)
|
||||
.join(', ');
|
||||
return { label, placeId: properties.osm_id ? String(properties.osm_id) : undefined };
|
||||
})
|
||||
.filter((suggestion: { label: string }) => suggestion.label)
|
||||
.slice(0, 5);
|
||||
|
||||
return NextResponse.json({ suggestions });
|
||||
} catch (error) {
|
||||
console.error('[PLACES] Autocomplete request failed:', error);
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
}
|
||||
@ -37,11 +37,18 @@ const REMINDER_PRESETS = [
|
||||
{ label: '2 hours before', value: 120 },
|
||||
{ label: '12 hours before', value: 720 },
|
||||
{ label: '1 day before', value: 1440 },
|
||||
{ label: '2 days before', value: 2880 },
|
||||
{ label: '1 week before', value: 10080 },
|
||||
];
|
||||
|
||||
const CUSTOM_REMINDER_SENTINEL = -9999;
|
||||
|
||||
interface LocationSuggestion {
|
||||
label: string;
|
||||
source: 'saved' | 'openstreetmap';
|
||||
placeId?: string;
|
||||
}
|
||||
|
||||
function formatReminderMinutes(minutes: number, language: string): string {
|
||||
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
|
||||
if (minutes % 10080 === 0) {
|
||||
@ -196,18 +203,48 @@ export default function CalendarEventModal({
|
||||
|
||||
// Location autocomplete
|
||||
const [locationFocused, setLocationFocused] = useState(false);
|
||||
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
|
||||
const [locationSuggestions, setLocationSuggestions] = useState<LocationSuggestion[]>([]);
|
||||
const locationRef = useRef<HTMLDivElement>(null);
|
||||
const locationRequestRef = useRef(0);
|
||||
const locationSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const updateLocationSuggestions = useCallback((val: string) => {
|
||||
if (!val.trim() || savedLocations.length === 0) {
|
||||
setLocationSuggestions([]);
|
||||
return;
|
||||
}
|
||||
const lower = val.toLowerCase();
|
||||
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
|
||||
setLocationSuggestions(matches.slice(0, 6));
|
||||
}, [savedLocations]);
|
||||
const requestId = ++locationRequestRef.current;
|
||||
if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
|
||||
|
||||
const trimmed = val.trim();
|
||||
const localMatches: LocationSuggestion[] = trimmed
|
||||
? savedLocations
|
||||
.filter(l => l.toLowerCase().includes(trimmed.toLowerCase()) && l !== val)
|
||||
.slice(0, 4)
|
||||
.map(label => ({ label, source: 'saved' }))
|
||||
: [];
|
||||
setLocationSuggestions(localMatches);
|
||||
|
||||
if (trimmed.length < 2) return;
|
||||
|
||||
locationSearchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
input: trimmed,
|
||||
languageCode: language.slice(0, 10),
|
||||
});
|
||||
const response = await fetch(`/api/places/autocomplete?${params}`, { cache: 'no-store' });
|
||||
if (!response.ok || requestId !== locationRequestRef.current) return;
|
||||
const data = await response.json();
|
||||
const mapMatches: LocationSuggestion[] = (data.suggestions || [])
|
||||
.filter((suggestion: any) => suggestion.label)
|
||||
.map((suggestion: any) => ({ ...suggestion, source: 'openstreetmap' }));
|
||||
const localLabels = new Set(localMatches.map(suggestion => suggestion.label.toLowerCase()));
|
||||
setLocationSuggestions([
|
||||
...localMatches,
|
||||
...mapMatches.filter(suggestion => !localLabels.has(suggestion.label.toLowerCase())),
|
||||
].slice(0, 7));
|
||||
} catch {
|
||||
// Saved-location suggestions remain available if Google is unavailable.
|
||||
}
|
||||
}, 250);
|
||||
}, [language, savedLocations]);
|
||||
|
||||
// Custom reminder state
|
||||
// customReminderIdx tracks which reminder row is showing the custom editor
|
||||
@ -238,7 +275,10 @@ export default function CalendarEventModal({
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handler);
|
||||
if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Focus trap
|
||||
@ -818,10 +858,10 @@ export default function CalendarEventModal({
|
||||
>
|
||||
{locationSuggestions.map((suggestion, i) => (
|
||||
<div
|
||||
key={i}
|
||||
key={`${suggestion.source}-${suggestion.placeId || suggestion.label}-${i}`}
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
|
||||
onMouseDown={e => { e.preventDefault(); setLocation(suggestion.label); setLocationSuggestions([]); }}
|
||||
style={{
|
||||
padding: '6px 10px', fontSize: '0.78rem',
|
||||
cursor: 'pointer', color: 'var(--weekly-text)',
|
||||
@ -831,9 +871,17 @@ export default function CalendarEventModal({
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion.label}</span>
|
||||
{suggestion.source === 'openstreetmap' && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.65rem', opacity: 0.5, whiteSpace: 'nowrap' }}>OpenStreetMap</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{locationSuggestions.some(suggestion => suggestion.source === 'openstreetmap') && (
|
||||
<div style={{ padding: '3px 10px', fontSize: '0.62rem', color: 'var(--weekly-text-light)', textAlign: 'right' }}>
|
||||
© OpenStreetMap contributors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user