mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
feat(places): unified file import modal with drag-and-drop and deduplication
- Replace separate GPX and KML/KMZ import buttons with a single "Import file" modal accepting all three formats, with a drag-and-drop drop zone - Support dragging files directly onto the Places sidebar panel; overlay appears on hover and pre-loads the file into the modal on drop - Fix [object Object] description bug in KML imports caused by fast-xml-parser returning mixed-content nodes as objects; add stopNodes config and object guard in asTrimmedString - Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by unwrapping CDATA markers before tag stripping - Add import deduplication across all import paths (GPX, KML/KMZ, Google list, Naver list): reimporting skips places already in the trip by name (case-insensitive) or by coordinates (within ~11 m tolerance), with intra-batch dedup so duplicate placemarks within the same file are also collapsed - Fix KML route returning 400 "No valid Placemarks found" when all placemarks were valid but deduplicated; 400 now only fires when the file contains zero placemarks - Show a warning toast "All places were already in the trip" instead of a misleading success toast when a reimport produces zero new places (GPX, KML/KMZ, Google list, Naver list) - Add 8 new i18n keys across all 14 locales; remove 11 keys made unused by the modal consolidation
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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) => 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<HTMLInputElement>(null)
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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)
|
||||
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 () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} else {
|
||||
const result = await placesApi.importMapFile(tripId, file)
|
||||
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 () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } 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 canImport = !!file && !loading
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={handleClose}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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" }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
{t('places.importFile')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('places.importFileHint')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".gpx,.kml,.kmz"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span style={{ color: 'var(--accent)', pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
|
||||
) : file ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{file.name}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
background: 'var(--bg-tertiary)', padding: 10, marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('places.kmlKmzSummaryValues', {
|
||||
total: summary.totalPlacemarks,
|
||||
created: summary.createdCount,
|
||||
skipped: summary.skippedCount,
|
||||
})}
|
||||
</div>
|
||||
{summary.warnings?.length > 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#b45309', whiteSpace: 'pre-wrap' }}>
|
||||
{summary.warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)', padding: '8px 10px',
|
||||
fontSize: 12, color: '#b91c1c', whiteSpace: 'pre-wrap', marginBottom: 10,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!canImport}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: canImport ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
color: canImport ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||
fontSize: 13, fontWeight: 500, cursor: canImport ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{loading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -433,29 +433,29 @@ describe('Mobile day-picker (portal)', () => {
|
||||
// ── GPX import ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPX import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => {
|
||||
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
const clickSpy = vi.spyOn(fileInput, 'click');
|
||||
await user.click(screen.getByText(/GPX/i));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
await user.click(screen.getByText(/Import file/i));
|
||||
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
|
||||
// FormData POST hangs on CI — mock at the API boundary instead of MSW.
|
||||
it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
|
||||
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
|
||||
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { loadTrip });
|
||||
const addToast = vi.fn();
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
|
||||
await user.click(screen.getByText(/Import file/i));
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /^import$/i }));
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -12,14 +12,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesImportSummary {
|
||||
totalPlacemarks: number
|
||||
createdCount: number
|
||||
skippedCount: number
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
}
|
||||
import FileImportModal from './FileImportModal'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -47,35 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const keyholeMarkupFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
|
||||
const importFileLimitBytes = 10 * 1024 * 1024
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await loadTrip(tripId)
|
||||
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 () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
||||
const sidebarDragCounter = useRef(0)
|
||||
|
||||
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
||||
if (!canEditPlaces) return
|
||||
e.preventDefault()
|
||||
sidebarDragCounter.current++
|
||||
setSidebarDragOver(true)
|
||||
}
|
||||
|
||||
const handleSidebarDragOver = (e: React.DragEvent) => {
|
||||
if (!canEditPlaces) return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleSidebarDragLeave = () => {
|
||||
sidebarDragCounter.current--
|
||||
if (sidebarDragCounter.current === 0) setSidebarDragOver(false)
|
||||
}
|
||||
|
||||
const handleSidebarDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
sidebarDragCounter.current = 0
|
||||
setSidebarDragOver(false)
|
||||
if (!canEditPlaces) return
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (!f) return
|
||||
setSidebarDropFile(f)
|
||||
setFileImportOpen(true)
|
||||
}
|
||||
|
||||
const [listImportOpen, setListImportOpen] = useState(false)
|
||||
@@ -84,68 +85,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
const [keyholeMarkupFileOpen, setKeyholeMarkupFileOpen] = useState(false)
|
||||
const [keyholeMarkupFileLoading, setKeyholeMarkupFileLoading] = useState(false)
|
||||
const [keyholeMarkupFile, setKeyholeMarkupFileFile] = useState<File | null>(null)
|
||||
const [keyholeMarkupFileSummary, setKeyholeMarkupFileSummary] = useState<PlacesImportSummary | null>(null)
|
||||
const [keyholeMarkupFileError, setKeyholeMarkupFileError] = useState('')
|
||||
|
||||
const resetKeyholeMarkupFileDialog = () => {
|
||||
setKeyholeMarkupFileFile(null)
|
||||
setKeyholeMarkupFileSummary(null)
|
||||
setKeyholeMarkupFileError('')
|
||||
setKeyholeMarkupFileLoading(false)
|
||||
}
|
||||
|
||||
const handleKeyholeMarkupFileImport = async () => {
|
||||
if (!keyholeMarkupFile) return
|
||||
|
||||
const ext = keyholeMarkupFile.name.toLowerCase().split('.').pop()
|
||||
if (ext !== 'kml' && ext !== 'kmz') {
|
||||
setKeyholeMarkupFileError(t('places.kmlKmzInvalidType'))
|
||||
return
|
||||
}
|
||||
if (keyholeMarkupFile.size > importFileLimitBytes) {
|
||||
setKeyholeMarkupFileError(t('places.kmlKmzTooLarge', { maxMb: 10 }))
|
||||
return
|
||||
}
|
||||
|
||||
setKeyholeMarkupFileLoading(true)
|
||||
setKeyholeMarkupFileError('')
|
||||
setKeyholeMarkupFileSummary(null)
|
||||
|
||||
try {
|
||||
const result = await placesApi.importMapFile(tripId, keyholeMarkupFile)
|
||||
|
||||
await loadTrip(tripId)
|
||||
setKeyholeMarkupFileSummary(result.summary || null)
|
||||
toast.success(t('places.kmlKmzImported', { count: result.count }))
|
||||
|
||||
if (result.summary?.errors?.length > 0) {
|
||||
setKeyholeMarkupFileError(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 () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined
|
||||
if (responseSummary) {
|
||||
setKeyholeMarkupFileSummary(responseSummary)
|
||||
}
|
||||
const message = err?.response?.data?.error || t('places.kmlKmzImportError')
|
||||
setKeyholeMarkupFileError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setKeyholeMarkupFileLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaverListImportEnabled && listImportProvider === 'naver') {
|
||||
@@ -162,7 +101,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
} else {
|
||||
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||
}
|
||||
setListImportOpen(false)
|
||||
setListImportUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
@@ -214,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDragLeave={handleSidebarDragLeave}
|
||||
onDrop={handleSidebarDrop}
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}
|
||||
>
|
||||
{sidebarDragOver && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 10,
|
||||
background: 'color-mix(in srgb, var(--accent) 12%, transparent)',
|
||||
border: '2px dashed var(--accent)',
|
||||
borderRadius: 4,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 10, pointerEvents: 'none',
|
||||
}}>
|
||||
<Upload size={28} strokeWidth={1.5} color="var(--accent)" />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)' }}>{t('places.sidebarDrop')}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Kopfbereich */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{canEditPlaces && <button
|
||||
@@ -229,10 +191,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>}
|
||||
{canEditPlaces && <>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
@@ -241,19 +202,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { resetKeyholeMarkupFileDialog(); setKeyholeMarkupFileOpen(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importKeyholeMarkup')}
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setListImportOpen(true)}
|
||||
@@ -619,122 +568,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{keyholeMarkupFileOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => { setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
{t('places.importKeyholeMarkup')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('places.kmlKmzHint')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={keyholeMarkupFileInputRef}
|
||||
type="file"
|
||||
accept=".kml,.kmz"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0] || null
|
||||
setKeyholeMarkupFileFile(file)
|
||||
setKeyholeMarkupFileSummary(null)
|
||||
setKeyholeMarkupFileError('')
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => keyholeMarkupFileInputRef.current?.click()}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
border: '1px dashed var(--border-primary)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
marginBottom: 12,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={14} strokeWidth={2} />
|
||||
{keyholeMarkupFile ? t('places.kmlKmzSelectedFile', { name: keyholeMarkupFile.name }) : t('places.kmlKmzSelectFile')}
|
||||
</button>
|
||||
|
||||
{keyholeMarkupFileSummary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
background: 'var(--bg-tertiary)', padding: 10, marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('places.kmlKmzSummaryValues', {
|
||||
total: keyholeMarkupFileSummary.totalPlacemarks,
|
||||
created: keyholeMarkupFileSummary.createdCount,
|
||||
skipped: keyholeMarkupFileSummary.skippedCount,
|
||||
})}
|
||||
</div>
|
||||
{keyholeMarkupFileSummary.warnings?.length > 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#b45309', whiteSpace: 'pre-wrap' }}>
|
||||
{keyholeMarkupFileSummary.warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyholeMarkupFileError && (
|
||||
<div style={{
|
||||
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)', padding: '8px 10px',
|
||||
fontSize: 12, color: '#b91c1c', whiteSpace: 'pre-wrap', marginBottom: 10,
|
||||
}}>
|
||||
{keyholeMarkupFileError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 12 }}>
|
||||
{t('places.kmlKmzSizeHint', { maxMb: 10 })}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleKeyholeMarkupFileImport}
|
||||
disabled={!keyholeMarkupFile || keyholeMarkupFileLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{keyholeMarkupFileLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<FileImportModal
|
||||
isOpen={fileImportOpen}
|
||||
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
|
||||
tripId={tripId}
|
||||
pushUndo={pushUndo}
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user