Files
TREK/client/src/components/Planner/FileImportModal.tsx
T
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00

371 lines
14 KiB
TypeScript

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) => 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 (
<button type="button" role="checkbox" aria-checked={checked} onClick={onToggle}
className="flex w-full items-center gap-2.5 rounded-[10px] px-2 py-1.5 text-start hover:bg-surface-hover">
<span className={`grid h-4 w-4 flex-none place-items-center rounded-[5px] ${checked ? 'bg-accent' : 'border-[1.5px] border-edge'}`}>
{checked && <Check size={11} strokeWidth={3} className="text-accent-text" />}
</span>
<span className="select-none text-content" style={fs(12.5, 'body')}>{label}</span>
</button>
)
}
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<HTMLInputElement>(null)
const titleId = useId()
const [files, setFiles] = useState<File[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(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<HTMLInputElement>) => {
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 = <span className="pointer-events-none font-semibold text-accent">{t('places.importFileDropActive')}</span>
else if (files.length > 0) zoneText = <span className="pointer-events-none break-all text-center font-semibold text-content">{files.map(f => f.name).join(', ')}</span>
else zoneText = <span className="pointer-events-none text-center text-content-faint">{t('places.importFileDropHere')}</span>
return (
<DialogShell
onClose={handleClose}
labelledBy={titleId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><FileDown size={20} strokeWidth={1.9} className="text-content" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={titleId}
onClose={handleClose}
title={t('places.importFile')}
sub={t('places.importFileHint')}
subWraps
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={handleClose}>{t('common.cancel')}</DialogButton>
<DialogButton
variant="primary"
onClick={handleImport}
disabled={!canImport}
icon={loading ? <Loader2 size={14} className="animate-spin" /> : undefined}
>
{loading ? t('common.loading') : t('common.import')}
</DialogButton>
</DialogFooter>
)}
>
<input
ref={fileInputRef}
type="file"
accept=".gpx,.kml,.kmz"
multiple
className="hidden"
onChange={handleInputChange}
/>
<EditorField label={t('files.title')}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex min-h-[96px] w-full flex-col items-center justify-center gap-1.5 rounded-[14px] border-2 border-dashed p-4 font-medium transition-colors ${isDragOver ? 'border-accent bg-surface-tertiary' : 'border-edge bg-surface-secondary hover:border-content-faint'}`}
style={fs(13, 'body')}
>
<Upload size={18} strokeWidth={1.8} className={`pointer-events-none ${isDragOver ? 'text-accent' : 'text-content-faint'}`} />
{zoneText}
</button>
</EditorField>
{isGpx && (
<DialogSection label={t('places.gpxImportTypes')}>
<div className={TYPE_BOX}>
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
<TypeCheck
key={key}
checked={gpxOpts[key]}
onToggle={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))}
label={t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
/>
))}
</div>
{gpxNoneSelected && <p className="m-0 mt-1.5 text-warning" style={fs(11.5)}>{t('places.gpxImportNoneSelected')}</p>}
</DialogSection>
)}
{isKml && (
<DialogSection label={t('places.kmlImportTypes')}>
<div className={TYPE_BOX}>
{(['points', 'paths'] as const).map(key => (
<TypeCheck
key={key}
checked={kmlOpts[key]}
onToggle={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))}
label={t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
/>
))}
</div>
{kmlNoneSelected && <p className="m-0 mt-1.5 text-warning" style={fs(11.5)}>{t('places.kmlImportNoneSelected')}</p>}
</DialogSection>
)}
{canEnrichImport && ((isGpx && gpxOpts.waypoints) || (isKml && kmlOpts.points)) && (
<div className="flex items-start gap-3 rounded-[14px] border border-edge-faint bg-surface-secondary p-3">
<div className="min-w-0 flex-1">
<div className="font-semibold text-content" style={fs(12.5, 'body')}>{t('places.enrichOnImport')}</div>
<div id={enrichHintId} className="mt-0.5 text-content-faint" style={fs(12, 'body')}>{t('places.enrichOnImportFileHint')}</div>
</div>
<ToggleSwitch on={enrich} onToggle={() => setEnrich(v => !v)} label={t('places.enrichOnImport')} describedBy={enrichHintId} />
</div>
)}
{summary && (
<div className="flex flex-col gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary px-3 py-2.5">
<div className="text-content-muted" style={fs(12, 'body')}>
{t('places.kmlKmzSummaryValues', {
total: summary.totalPlacemarks,
created: summary.createdCount,
skipped: summary.skippedCount,
})}
</div>
{summary.warnings?.length > 0 && (
<div className="whitespace-pre-wrap rounded-[10px] bg-warning-soft px-2.5 py-2 text-warning" style={fs(12, 'body')}>
{summary.warnings.join('\n')}
</div>
)}
</div>
)}
{error && (
<div role="alert" className="whitespace-pre-wrap rounded-[12px] bg-danger-soft px-3 py-2 text-danger" style={fs(12, 'body')}>
{error}
</div>
)}
</DialogShell>
)
}