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 [file, setFile] = useState(null) 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 = () => { setFile(null) 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) { setFile(null) setError(err) } else { setFile(initialFile) setError('') } } else { setFile(null) 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 selectFile = (f: File) => { const validationError = validateFile(f) if (validationError) { setError(validationError) setFile(null) return } setFile(f) setError('') setSummary(null) } const handleInputChange = (e: React.ChangeEvent) => { const f = e.target.files?.[0] e.target.value = '' if (f) selectFile(f) } 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 f = e.dataTransfer.files[0] if (f) selectFile(f) } const handleImport = async () => { if (!file || loading) return const ext = file.name.toLowerCase().split('.').pop() setLoading(true) setError('') setSummary(null) try { if (ext === 'gpx') { const result = await placesApi.importGpx(tripId, file, gpxOpts) await loadTrip(tripId) if (result.count === 0 && result.skipped > 0) { toast.warning(t('places.importAllSkipped')) } else { toast.success(t('places.gpxImported', { count: result.count })) } if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importGpx'), async () => { try { await placesApi.bulkDelete(tripId, importedIds) } catch {} await loadTrip(tripId) }) } handleClose() } else { const result = await placesApi.importMapFile(tripId, file, kmlOpts) await loadTrip(tripId) setSummary(result.summary || null) if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { toast.warning(t('places.importAllSkipped')) } else { toast.success(t('places.kmlKmzImported', { count: result.count })) } if (result.summary?.errors?.length > 0) { setError(result.summary.errors.join('\n')) } if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importKeyholeMarkup'), async () => { try { await placesApi.bulkDelete(tripId, importedIds) } catch {} await loadTrip(tripId) }) } } } catch (err: any) { const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined if (responseSummary) setSummary(responseSummary) const message = err?.response?.data?.error || t('places.importFileError') setError(message) toast.error(message) } finally { setLoading(false) } } const fileExt = file?.name.toLowerCase().split('.').pop() ?? '' const isGpx = fileExt === 'gpx' const isKml = fileExt === 'kml' || fileExt === 'kmz' const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected if (!isOpen) return null return ReactDOM.createPortal(
e.stopPropagation()} style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} >
{t('places.importFile')}
{t('places.importFileHint')}
fileInputRef.current?.click()} onDragOver={handleDragOver} onDragEnter={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ width: '100%', minHeight: 88, borderRadius: 12, border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, background: isDragOver ? 'var(--bg-tertiary)' : 'transparent', 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')} ) : file ? ( {file.name} ) : ( {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 ) }