import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useEffect } from 'react' import { Upload } from 'lucide-react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' 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 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 [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 }) 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) 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) 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 return ReactDOM.createPortal(
e.stopPropagation()} className="bg-surface-card" style={{ borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)" }} >
{t('places.importFile')}
{t('places.importFileHint')}
fileInputRef.current?.click()} onDragOver={handleDragOver} onDragEnter={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'} style={{ width: '100%', minHeight: 88, borderRadius: 12, border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', marginBottom: 12, fontFamily: 'inherit', transition: 'border-color 0.15s, background 0.15s', boxSizing: 'border-box', padding: 16, }} > {isDragOver ? ( {t('places.importFileDropActive')} ) : files.length > 0 ? ( {files.map(f => f.name).join(', ')} ) : ( {t('places.importFileDropHere')} )}
{isGpx && (
{t('places.gpxImportTypes')}
{(['waypoints', 'routes', 'tracks'] as const).map(key => ( ))} {gpxNoneSelected && (
{t('places.gpxImportNoneSelected')}
)}
)} {isKml && (
{t('places.kmlImportTypes')}
{(['points', 'paths'] as const).map(key => ( ))} {kmlNoneSelected && (
{t('places.kmlImportNoneSelected')}
)}
)} {summary && (
{t('places.kmlKmzSummaryValues', { total: summary.totalPlacemarks, created: summary.createdCount, skipped: summary.skippedCount, })}
{summary.warnings?.length > 0 && (
{summary.warnings.join('\n')}
)}
)} {error && (
{error}
)}
, document.body ) }