From d60ab3672e4b7e224fd19701f134fe7d6d603a37 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:32:00 +0200 Subject: [PATCH] feat(client): add KMZ/KML places import dialog and API --- client/src/api/client.ts | 8 + .../src/components/Planner/PlacesSidebar.tsx | 188 ++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 237d3e64..df74ae96 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -105,6 +105,14 @@ export const placesApi = { 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) }, + importKml: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/kml`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, + importKmz: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/kmz`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, importGoogleList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), } diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index ce8c6c51..40ece73e 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -14,6 +14,14 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' +interface PlacesImportSummary { + totalPlacemarks: number + createdCount: number + skippedCount: number + warnings: string[] + errors: string[] +} + interface PlacesSidebarProps { tripId: number places: Place[] @@ -44,6 +52,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) + const importFileLimitBytes = 10 * 1024 * 1024 const handleGpxImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -70,6 +79,70 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [googleListOpen, setGoogleListOpen] = useState(false) const [googleListUrl, setGoogleListUrl] = useState('') const [googleListLoading, setGoogleListLoading] = useState(false) + const [kmlKmzOpen, setKmlKmzOpen] = useState(false) + const [kmlKmzLoading, setKmlKmzLoading] = useState(false) + const [kmlKmzFile, setKmlKmzFile] = useState(null) + const [kmlKmzSummary, setKmlKmzSummary] = useState(null) + const [kmlKmzError, setKmlKmzError] = useState('') + + const resetKmlKmzDialog = () => { + setKmlKmzFile(null) + setKmlKmzSummary(null) + setKmlKmzError('') + setKmlKmzLoading(false) + } + + const handleKmlKmzImport = async () => { + if (!kmlKmzFile) return + + const ext = kmlKmzFile.name.toLowerCase().split('.').pop() + if (ext !== 'kml' && ext !== 'kmz') { + setKmlKmzError(t('places.kmlKmzInvalidType')) + return + } + if (kmlKmzFile.size > importFileLimitBytes) { + setKmlKmzError(t('places.kmlKmzTooLarge', { maxMb: 10 })) + return + } + + setKmlKmzLoading(true) + setKmlKmzError('') + setKmlKmzSummary(null) + + try { + const result = ext === 'kmz' + ? await placesApi.importKmz(tripId, kmlKmzFile) + : await placesApi.importKml(tripId, kmlKmzFile) + + await loadTrip(tripId) + setKmlKmzSummary(result.summary || null) + toast.success(t('places.kmlKmzImported', { count: result.count })) + + if (result.summary?.errors?.length > 0) { + setKmlKmzError(result.summary.errors.join('\n')) + } + + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importKmlKmz'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + } catch (err: any) { + const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined + if (responseSummary) { + setKmlKmzSummary(responseSummary) + } + const message = err?.response?.data?.error || t('places.kmlKmzImportError') + setKmlKmzError(message) + toast.error(message) + } finally { + setKmlKmzLoading(false) + } + } const handleGoogleListImport = async () => { if (!googleListUrl.trim()) return @@ -159,6 +232,18 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ > {t('places.importGpx')} + + + + + , + document.body + )} )