diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 346b7d8..3301a26 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -91,6 +91,10 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), + importGpx: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, } export const assignmentsApi = { diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 14f3129..3bef079 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,15 +1,19 @@ import ReactDOM from 'react-dom' -import { useState } from 'react' +import { useState, useRef } from 'react' import DOM from 'react-dom' -import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react' +import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' +import { placesApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' interface PlacesSidebarProps { + tripId: number places: Place[] categories: Category[] assignments: AssignmentsMap @@ -26,11 +30,27 @@ interface PlacesSidebarProps { } export default function PlacesSidebar({ - places, categories, assignments, selectedDayId, selectedPlaceId, + tripId, places, categories, assignments, selectedDayId, selectedPlaceId, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, }: PlacesSidebarProps) { const { t } = useTranslation() + const toast = useToast() const ctxMenu = useContextMenu() + const gpxInputRef = useRef(null) + const tripStore = useTripStore() + + const handleGpxImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + try { + const result = await placesApi.importGpx(tripId, file) + await tripStore.loadTrip(tripId) + toast.success(t('places.gpxImported', { count: result.count })) + } catch (err: any) { + toast.error(err?.response?.data?.error || t('places.gpxError')) + } + } const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [categoryFilter, setCategoryFilterLocal] = useState('') @@ -72,6 +92,19 @@ export default function PlacesSidebar({ > {t('places.addPlace')} + + {/* Filter-Tabs */}
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index b09273e..0aea7ff 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -638,6 +638,9 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', + 'places.importGpx': 'GPX importieren', + 'places.gpxImported': '{count} Orte aus GPX importiert', + 'places.gpxError': 'GPX-Import fehlgeschlagen', 'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.all': 'Alle', 'places.unplanned': 'Ungeplant', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b85b6dc..46e6310 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -638,6 +638,9 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', + 'places.importGpx': 'Import GPX', + 'places.gpxImported': '{count} places imported from GPX', + 'places.gpxError': 'GPX import failed', 'places.assignToDay': 'Add to which day?', 'places.all': 'All', 'places.unplanned': 'Unplanned', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index b844c1d..8992746 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -495,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )}
{mobileSidebarOpen === 'left' ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> - : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> + : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> }
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 176aa3c..f304e6a 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from 'express'; import fetch from 'node-fetch'; +import multer from 'multer'; import { db, getPlaceWithTags } from '../db/database'; import { authenticate } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; @@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers'; import { validateStringLengths } from '../middleware/validate'; import { AuthRequest, Place } from '../types'; +const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); + interface PlaceWithCategory extends Place { category_name: string | null; category_color: string | null; @@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); +// Import places from GPX file (must be before /:id) +router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => { + const { tripId } = req.params; + const file = (req as any).file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + const xml = file.buffer.toString('utf-8'); + + const parseCoords = (attrs: string): { lat: number; lng: number } | null => { + const latMatch = attrs.match(/lat=["']([^"']+)["']/i); + const lonMatch = attrs.match(/lon=["']([^"']+)["']/i); + if (!latMatch || !lonMatch) return null; + const lat = parseFloat(latMatch[1]); + const lng = parseFloat(lonMatch[1]); + return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null; + }; + + const stripCdata = (s: string) => s.replace(//g, '$1').trim(); + const extractName = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null }; + const extractDesc = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null }; + + const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = []; + + // 1) Parse elements (named waypoints / POIs) + const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi; + let match; + while ((match = wptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + + // 2) If no , try (route points) + if (waypoints.length === 0) { + const rteptRegex = /]+)>([\s\S]*?)<\/rtept>/gi; + while ((match = rteptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + } + + // 3) If still nothing, extract track name + start/end points from + if (waypoints.length === 0) { + const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); + const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; + const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; + const trackPoints: { lat: number; lng: number }[] = []; + while ((match = trkptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (coords) trackPoints.push(coords); + } + if (trackPoints.length > 0) { + const start = trackPoints[0]; + waypoints.push({ ...start, name: `${trackName} — Start`, description: null }); + if (trackPoints.length > 1) { + const end = trackPoints[trackPoints.length - 1]; + waypoints.push({ ...end, name: `${trackName} — End`, description: null }); + } + } + } + + if (waypoints.length === 0) { + return res.status(400).json({ error: 'No waypoints found in GPX file' }); + } + + const insertStmt = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, transport_mode) + VALUES (?, ?, ?, ?, ?, 'walking') + `); + const created: any[] = []; + const insertAll = db.transaction(() => { + for (const wp of waypoints) { + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng); + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + } + }); + insertAll(); + + res.status(201).json({ places: created, count: created.length }); + for (const place of created) { + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); + } +}); + router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { const { tripId, id } = req.params