import React from 'react' import { useState, useRef, useEffect, useId } from 'react' import { Check, FileDown, Loader2, Upload } from 'lucide-react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useAuthStore } from '../../store/authStore' import ToggleSwitch from '../Settings/ToggleSwitch' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs, } from '../shared/DialogShell' import { EditorField } from '../shared/dialogParts' interface PlacesImportSummary { totalPlacemarks: number createdCount: number skippedCount: number warnings: string[] errors: string[] } interface FileImportModalProps { isOpen: boolean onClose: () => void tripId: number pushUndo?: (label: string, undoFn: () => Promise | void) => void initialFile?: File | null } const MAX_FILE_BYTES = 10 * 1024 * 1024 /** One tickable kind of content in the file (waypoints, paths…). */ function TypeCheck({ checked, label, onToggle }: { checked: boolean; label: string; onToggle: () => void }) { return ( ) } const TYPE_BOX = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5' export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, initialFile }: FileImportModalProps) { const { t } = useTranslation() const toast = useToast() const loadTrip = useTripStore((s) => s.loadTrip) const fileInputRef = useRef(null) const titleId = useId() const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [summary, setSummary] = useState(null) const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true }) const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true }) // The Google pass a list import offers, for the points of a file too (#2536). const canEnrichImport = useAuthStore(s => s.hasMapsKey) const [enrich, setEnrich] = useState(false) const enrichHintId = useId() const validateFile = (f: File): string | null => { const ext = f.name.toLowerCase().split('.').pop() if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') { return t('places.importFileUnsupported') } if (f.size > MAX_FILE_BYTES) { return t('places.importFileTooLarge', { maxMb: 10 }) } return null } const reset = () => { setFiles([]) setIsDragOver(false) setLoading(false) setError('') setSummary(null) } // When the modal opens, reset state and pre-load any file dropped from the sidebar. useEffect(() => { if (!isOpen) return setIsDragOver(false) setLoading(false) setSummary(null) if (initialFile) { const err = validateFile(initialFile) if (err) { setFiles([]) setError(err) } else { setFiles([initialFile]) setError('') } } else { setFiles([]) setError('') } // validateFile uses t() which is stable — intentionally omitted from deps // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, initialFile]) const handleClose = () => { reset() onClose() } const selectFiles = (incoming: File[]) => { if (incoming.length === 0) return const valid: File[] = [] let firstError: string | null = null for (const f of incoming) { const validationError = validateFile(f) if (validationError) { firstError = firstError ?? validationError continue } valid.push(f) } if (valid.length === 0) { setError(firstError ?? '') setFiles([]) return } setFiles(valid) setError(firstError ?? '') setSummary(null) } const handleInputChange = (e: React.ChangeEvent) => { const list = e.target.files ? Array.from(e.target.files) : [] e.target.value = '' if (list.length) selectFiles(list) } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const list = Array.from(e.dataTransfer.files) if (list.length) selectFiles(list) } const handleImport = async () => { if (files.length === 0 || loading) return setLoading(true) setError('') setSummary(null) let totalCreated = 0 let totalSkipped = 0 const createdIds: number[] = [] const errors: string[] = [] let mergedSummary: PlacesImportSummary | null = null let importedGpx = false let importedKml = false for (const f of files) { const ext = f.name.toLowerCase().split('.').pop() try { if (ext === 'gpx') { importedGpx = true const result = await placesApi.importGpx(tripId, f, { ...gpxOpts, enrich: enrich && canEnrichImport }) totalCreated += result.count ?? 0 totalSkipped += result.skipped ?? 0 if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id)) } else { importedKml = true const result = await placesApi.importMapFile(tripId, f, { ...kmlOpts, enrich: enrich && canEnrichImport }) totalCreated += result.count ?? 0 if (result.places?.length > 0) createdIds.push(...result.places.map((p: { id: number }) => p.id)) const s = result.summary as PlacesImportSummary | undefined if (s) { mergedSummary = mergedSummary ? { totalPlacemarks: mergedSummary.totalPlacemarks + s.totalPlacemarks, createdCount: mergedSummary.createdCount + s.createdCount, skippedCount: mergedSummary.skippedCount + s.skippedCount, warnings: [...mergedSummary.warnings, ...(s.warnings ?? [])], errors: [...mergedSummary.errors, ...(s.errors ?? [])], } : s totalSkipped += s.skippedCount ?? 0 } } } catch (err: any) { const message = err?.response?.data?.error || t('places.importFileError') errors.push(files.length > 1 ? `${f.name}: ${message}` : message) } } await loadTrip(tripId) if (createdIds.length > 0) { pushUndo?.(importedGpx && !importedKml ? t('undo.importGpx') : t('undo.importKeyholeMarkup'), async () => { try { await placesApi.bulkDelete(tripId, createdIds) } catch {} await loadTrip(tripId) }) } if (totalCreated > 0) { const key = importedKml && !importedGpx ? 'places.kmlKmzImported' : 'places.gpxImported' toast.success(t(key, { count: totalCreated })) } else if (totalSkipped > 0 && errors.length === 0) { toast.warning(t('places.importAllSkipped')) } if (mergedSummary) setSummary(mergedSummary) if (errors.length > 0) { setError(errors.join('\n')) toast.error(errors[0]) } setLoading(false) // Close once everything succeeded and there's no KML summary left to surface. if (errors.length === 0 && !mergedSummary) handleClose() } const exts = files.map(f => f.name.toLowerCase().split('.').pop() ?? '') const isGpx = exts.includes('gpx') const isKml = exts.some(e => e === 'kml' || e === 'kmz') const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths const canImport = files.length > 0 && !loading && !gpxNoneSelected && !kmlNoneSelected if (!isOpen) return null let zoneText: React.ReactNode if (isDragOver) zoneText = {t('places.importFileDropActive')} else if (files.length > 0) zoneText = {files.map(f => f.name).join(', ')} else zoneText = {t('places.importFileDropHere')} return ( } tint={NEUTRAL_TINT} labelId={titleId} onClose={handleClose} title={t('places.importFile')} sub={t('places.importFileHint')} subWraps /> )} footer={( {t('common.cancel')} : undefined} > {loading ? t('common.loading') : t('common.import')} )} > {isGpx && (
{(['waypoints', 'routes', 'tracks'] as const).map(key => ( setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} label={t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')} /> ))}
{gpxNoneSelected &&

{t('places.gpxImportNoneSelected')}

}
)} {isKml && (
{(['points', 'paths'] as const).map(key => ( setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} label={t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')} /> ))}
{kmlNoneSelected &&

{t('places.kmlImportNoneSelected')}

}
)} {canEnrichImport && ((isGpx && gpxOpts.waypoints) || (isKml && kmlOpts.points)) && (
{t('places.enrichOnImport')}
{t('places.enrichOnImportFileHint')}
setEnrich(v => !v)} label={t('places.enrichOnImport')} describedBy={enrichHintId} />
)} {summary && (
{t('places.kmlKmzSummaryValues', { total: summary.totalPlacemarks, created: summary.createdCount, skipped: summary.skippedCount, })}
{summary.warnings?.length > 0 && (
{summary.warnings.join('\n')}
)}
)} {error && (
{error}
)}
) }