import React, { useMemo, useRef, useState } from 'react' import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info, Plus, FolderInput, Check, Search, Bookmark } from 'lucide-react' import Modal from '../shared/Modal' import { MAX_COLLECTION_FILE_PLACES, type Collection, type CollectionFile } from '@trek/shared' import type { TranslationFn } from '../../types' import { getApiErrorMessage } from '../../types' import { readCollectionFile, type CollectionFileError, type GpxLeftovers, type GpxReader, COLLECTION_FILE_EXTENSION, COLLECTION_GPX_EXTENSION, } from './collectionFile' interface ImportCollectionModalProps { onImport: (file: CollectionFile, name?: string) => Promise /** Adds the file to a list that is already there. Without it the dialog only makes new ones. */ onImportInto?: (file: CollectionFile, collectionId: number) => Promise /** The lists this person may add to; the server applies the same rule again. */ lists?: Collection[] /** The list that is open, so the obvious target is the one already selected. */ defaultListId?: number | null /** Reads a GPX into a list file (#2301); the server does the parsing. */ onReadGpx: GpxReader onClose: () => void t: TranslationFn } /** Keyed by the error type, so a new way for a file to fail does not build without its words. */ const ERROR_KEYS: Record = { 'too-large': 'collections.file.errorTooLarge', unreadable: 'collections.file.errorUnreadable', 'not-a-collection': 'collections.file.errorNotACollection', 'not-gpx': 'collections.file.errorNotGpx', 'too-many-places': 'collections.file.errorTooManyPlaces', } /** What a GPX held besides its places, said before anything is imported. */ function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; placeCount: number; t: TranslationFn }) { const notes = [ placeCount === 0 && t('collections.file.gpxEmpty'), leftovers.skipped > 0 && t('collections.file.gpxSkipped', { count: leftovers.skipped }), leftovers.trackPoints > 0 && t('collections.file.gpxTrack', { count: leftovers.trackPoints }), ].filter(Boolean) if (notes.length === 0) return null return (
    {notes.map(note => (
  • {note}
  • ))}
) } /** One of the two answers to "where do these places go", as a card you can tap. */ function TargetCard({ active, icon: Icon, title, hint, onClick }: { active: boolean icon: typeof Plus title: string hint: string onClick: () => void }) { return ( ) } /** The lists the file may be added to, the open one first. */ function ListChoice({ lists, selectedId, onSelect, t }: { lists: Collection[] selectedId: number | null onSelect: (id: number) => void t: TranslationFn }) { const [search, setSearch] = useState('') const shown = useMemo(() => { const q = search.trim().toLowerCase() return q ? lists.filter(l => l.name.toLowerCase().includes(q)) : lists }, [lists, search]) return (
{lists.length > 5 && (
setSearch(e.target.value)} placeholder={t('collections.file.searchLists')} className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent" />
)}
{shown.map(list => { const active = list.id === selectedId return ( ) })} {shown.length === 0 && (

{t('collections.noOtherLists')}

)}
) } /** * Read a list file or a GPX and make a list of it (#2198, #2301). * * Two steps on purpose. A file from somebody else is an unknown quantity, so * it is read and shown first — how many places, which labels, what the list is * called — and only then imported. The name is editable in the same breath, * because a file called "Lisbon" from a friend is usually worth calling * "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding * the list editor. * * A list file never leaves the browser as a file: it is parsed here. A GPX is * read by the server into the same kind of list file, and from then on the two * are one path: what goes to the import is a list file, through the same * contract the server validates against. * * The file can go into a list that is already there instead of a new one. That * is a choice rather than the default, because the two do different things: a * new list is the file as it stands, while adding to a list leaves everything * in it alone and skips the places it already has. */ export default function ImportCollectionModal({ onImport, onImportInto, lists, defaultListId, onReadGpx, onClose, t, }: ImportCollectionModalProps): React.ReactElement { const inputRef = useRef(null) const [file, setFile] = useState(null) const [leftovers, setLeftovers] = useState(null) const [name, setName] = useState('') const [error, setError] = useState(null) const [failedMessage, setFailedMessage] = useState(null) const [reading, setReading] = useState(false) const [busy, setBusy] = useState(false) const [dragging, setDragging] = useState(false) const [target, setTarget] = useState<'new' | 'existing'>('new') // The open list first: it is the one somebody importing from inside a list means. const targets = useMemo(() => { const all = lists ?? [] const open = all.find(l => l.id === defaultListId) return open ? [open, ...all.filter(l => l.id !== open.id)] : all }, [lists, defaultListId]) const [listId, setListId] = useState(() => targets[0]?.id ?? null) const canChoose = !!onImportInto && targets.length > 0 const intoExisting = canChoose && target === 'existing' const take = async (chosen: File | undefined) => { if (!chosen || reading) return setError(null) setFailedMessage(null) setReading(true) try { const result = await readCollectionFile(chosen, onReadGpx) setFile(result.file) setLeftovers(result.gpx ?? null) setError(result.error) if (result.file) setName(result.file.name) } catch (err) { setFile(null) setLeftovers(null) setError('failed') setFailedMessage(getApiErrorMessage(err, t('common.error'))) } finally { setReading(false) } } const onDrop = (e: React.DragEvent) => { e.preventDefault() setDragging(false) void take(e.dataTransfer.files?.[0]) } const submit = async () => { if (!file || busy) return setBusy(true) setError(null) setFailedMessage(null) try { if (intoExisting && listId != null) { await onImportInto!(file, listId) } else { const trimmed = name.trim() await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined) } } catch (err) { setError('failed') setFailedMessage(getApiErrorMessage(err, t('common.error'))) } finally { setBusy(false) } } const errorText = error === 'failed' ? (failedMessage ?? t('common.error')) : error ? t(ERROR_KEYS[error], { count: MAX_COLLECTION_FILE_PLACES }) : null // A GPX of nothing but a track would make an empty list, which is never what was meant. const nothingToImport = !!leftovers && !!file && file.places.length === 0 return ( } >
{ void take(e.target.files?.[0]); e.target.value = '' }} /> {!file ? ( ) : ( <>
{leftovers ? : } {file.name} {t('collections.placeCount', { count: file.places.length })} {file.labels && file.labels.length > 0 && ( {t('collections.file.labelCount', { count: file.labels.length })} )}
{file.description && (

{file.description}

)} {leftovers && } {canChoose && (
setTarget('new')} /> setTarget('existing')} />
)} {intoExisting ? ( <>

{t('collections.file.intoHint')}

) : ( <> {!leftovers &&

{t('collections.file.hint')}

} )} )} {errorText && (

{errorText}

)}
) }