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.
|
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
|
## [1.115.3] - 2026-09-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.115.3",
|
"version": "1.116.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.115.3",
|
"version": "1.116.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"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: '2 hours before', value: 120 },
|
||||||
{ label: '12 hours before', value: 720 },
|
{ label: '12 hours before', value: 720 },
|
||||||
{ label: '1 day before', value: 1440 },
|
{ label: '1 day before', value: 1440 },
|
||||||
|
{ label: '2 days before', value: 2880 },
|
||||||
{ label: '1 week before', value: 10080 },
|
{ label: '1 week before', value: 10080 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CUSTOM_REMINDER_SENTINEL = -9999;
|
const CUSTOM_REMINDER_SENTINEL = -9999;
|
||||||
|
|
||||||
|
interface LocationSuggestion {
|
||||||
|
label: string;
|
||||||
|
source: 'saved' | 'openstreetmap';
|
||||||
|
placeId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function formatReminderMinutes(minutes: number, language: string): string {
|
function formatReminderMinutes(minutes: number, language: string): string {
|
||||||
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
|
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
|
||||||
if (minutes % 10080 === 0) {
|
if (minutes % 10080 === 0) {
|
||||||
@ -196,18 +203,48 @@ export default function CalendarEventModal({
|
|||||||
|
|
||||||
// Location autocomplete
|
// Location autocomplete
|
||||||
const [locationFocused, setLocationFocused] = useState(false);
|
const [locationFocused, setLocationFocused] = useState(false);
|
||||||
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
|
const [locationSuggestions, setLocationSuggestions] = useState<LocationSuggestion[]>([]);
|
||||||
const locationRef = useRef<HTMLDivElement>(null);
|
const locationRef = useRef<HTMLDivElement>(null);
|
||||||
|
const locationRequestRef = useRef(0);
|
||||||
|
const locationSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const updateLocationSuggestions = useCallback((val: string) => {
|
const updateLocationSuggestions = useCallback((val: string) => {
|
||||||
if (!val.trim() || savedLocations.length === 0) {
|
const requestId = ++locationRequestRef.current;
|
||||||
setLocationSuggestions([]);
|
if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
|
||||||
return;
|
|
||||||
|
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.
|
||||||
}
|
}
|
||||||
const lower = val.toLowerCase();
|
}, 250);
|
||||||
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val);
|
}, [language, savedLocations]);
|
||||||
setLocationSuggestions(matches.slice(0, 6));
|
|
||||||
}, [savedLocations]);
|
|
||||||
|
|
||||||
// Custom reminder state
|
// Custom reminder state
|
||||||
// customReminderIdx tracks which reminder row is showing the custom editor
|
// customReminderIdx tracks which reminder row is showing the custom editor
|
||||||
@ -238,7 +275,10 @@ export default function CalendarEventModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handler);
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handler);
|
||||||
|
if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus trap
|
// Focus trap
|
||||||
@ -818,10 +858,10 @@ export default function CalendarEventModal({
|
|||||||
>
|
>
|
||||||
{locationSuggestions.map((suggestion, i) => (
|
{locationSuggestions.map((suggestion, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={`${suggestion.source}-${suggestion.placeId || suggestion.label}-${i}`}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={false}
|
aria-selected={false}
|
||||||
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
|
onMouseDown={e => { e.preventDefault(); setLocation(suggestion.label); setLocationSuggestions([]); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 10px', fontSize: '0.78rem',
|
padding: '6px 10px', fontSize: '0.78rem',
|
||||||
cursor: 'pointer', color: 'var(--weekly-text)',
|
cursor: 'pointer', color: 'var(--weekly-text)',
|
||||||
@ -831,9 +871,17 @@ export default function CalendarEventModal({
|
|||||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
>
|
>
|
||||||
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user