mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points, paths) to import. KML LineString placemarks are now imported as path places with route_geometry. Performance improvements: - Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut unnecessary re-renders in PlacesSidebar - Add weatherQueue to cap concurrent weather fetches at 3 - Replace sequential per-place deletes with a single bulkDelete API call (new DELETE /places/bulk endpoint + deletePlacesMany service) - Memoize atlas/photo/weather service calls to avoid redundant requests - Add multi-select mode to PlacesSidebar for bulk operations Add large GPX/KML/KMZ fixtures for integration/perf testing and two profiler analysis scripts under scripts/.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
@@ -16,10 +17,13 @@ vi.mock('react-leaflet', () => ({
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
@@ -100,17 +104,21 @@ describe('MapView', () => {
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
||||
})
|
||||
|
||||
@@ -190,11 +198,13 @@ describe('MapView', () => {
|
||||
vi.mocked(photoService.getCached).mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -367,6 +367,35 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
orderNumbers: number[] | null
|
||||
photoUrl: string | null
|
||||
onClickPlace: (id: number) => void
|
||||
onHover: (place: any, x: number, y: number) => void
|
||||
onHoverOut: () => void
|
||||
}
|
||||
|
||||
const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
return (
|
||||
<Marker
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -397,18 +426,48 @@ export const MapView = memo(function MapView({
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [])
|
||||
|
||||
const handleMarkerHoverOut = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
// Batch photo state updates through a RAF so N simultaneous photo loads
|
||||
// collapse into a single re-render instead of N separate renders.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
@@ -421,11 +480,9 @@ export const MapView = memo(function MapView({
|
||||
continue
|
||||
}
|
||||
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
@@ -433,7 +490,13 @@ export const MapView = memo(function MapView({
|
||||
}
|
||||
}
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
@@ -446,57 +509,49 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
<MemoMarker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
place={place}
|
||||
isSelected={isSelected}
|
||||
orderNumbers={orderNumbers}
|
||||
photoUrl={photoUrl}
|
||||
onClickPlace={handleMarkerClick}
|
||||
onHover={handleMarkerHover}
|
||||
onHoverOut={handleMarkerHoverOut}
|
||||
/>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -553,22 +608,40 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
{gpxPolylines}
|
||||
</MapContainer>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
top: tooltipPos.y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.name}
|
||||
</div>
|
||||
{hoveredPlace.category_name && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredPlace.address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -231,7 +231,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
@@ -1244,7 +1243,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const arrowMove = (direction: 'up' | 'down') => {
|
||||
@@ -1328,15 +1326,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
cursor: 'pointer',
|
||||
background: lockedIds.has(assignment.id)
|
||||
? 'rgba(220,38,38,0.08)'
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
@@ -1344,7 +1352,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1447,7 +1455,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -1471,7 +1479,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
@@ -1518,15 +1525,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
background: `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: spanPhase === 'middle' ? 0.65 : 1,
|
||||
@@ -1578,7 +1585,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
const NoteIcon = getNoteIcon(note.icon)
|
||||
const noteIdx = idx
|
||||
return (
|
||||
@@ -1615,20 +1621,30 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
]) : undefined}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 2px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
||||
background: 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
@@ -1642,11 +1658,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>}
|
||||
|
||||
@@ -36,6 +36,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
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 })
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
const ext = f.name.toLowerCase().split('.').pop()
|
||||
@@ -127,7 +129,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
try {
|
||||
if (ext === 'gpx') {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
const result = await placesApi.importGpx(tripId, file, gpxOpts)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -137,15 +139,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
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 {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} else {
|
||||
const result = await placesApi.importMapFile(tripId, file)
|
||||
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
|
||||
await loadTrip(tripId)
|
||||
setSummary(result.summary || null)
|
||||
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
|
||||
@@ -159,9 +159,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
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 {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -177,7 +175,12 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
}
|
||||
}
|
||||
|
||||
const canImport = !!file && !loading
|
||||
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
|
||||
|
||||
@@ -242,6 +245,58 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isKml && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
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 { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,6 +12,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -25,6 +26,8 @@ interface PlacesSidebarProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -32,9 +35,115 @@ interface PlacesSidebarProps {
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
place: Place
|
||||
category: Category | undefined
|
||||
isSelected: boolean
|
||||
isPlanned: boolean
|
||||
inDay: boolean
|
||||
isChecked: boolean
|
||||
selectMode: boolean
|
||||
selectedDayId: number | null
|
||||
canEditPlaces: boolean
|
||||
isMobile: boolean
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
onPlaceClick: (id: number | null) => void
|
||||
onContextMenu: (e: React.MouseEvent, place: Place) => void
|
||||
onAssignToDay: (placeId: number, dayId?: number) => void
|
||||
toggleSelected: (id: number) => void
|
||||
setDayPickerPlace: (place: any) => void
|
||||
}
|
||||
|
||||
const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||
}: MemoPlaceRowProps) {
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
draggable={!selectMode}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{selectMode && (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: isChecked ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
)}
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!selectMode && !inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -110,9 +219,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -126,6 +233,28 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
const placeIdSet = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [places])
|
||||
|
||||
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
@@ -140,12 +269,16 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
@@ -159,6 +292,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
||||
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
||||
|
||||
const inDaySet = useMemo(() => {
|
||||
if (!selectedDayId) return new Set<number>()
|
||||
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
}, [assignments, selectedDayId])
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||
const selDayId = selectedDayIdRef.current
|
||||
ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
@@ -219,13 +372,67 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
||||
</button>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
||||
>
|
||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) {
|
||||
setPendingDeleteIds(Array.from(selectedIds))
|
||||
} else {
|
||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
||||
</button>
|
||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
|
||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
@@ -240,7 +447,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
@@ -363,82 +570,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const inDay = isAssignedToSelectedDay(place.id)
|
||||
const isPlanned = plannedIds.has(place.id)
|
||||
|
||||
const inDay = inDaySet.has(place.id)
|
||||
const isChecked = selectedIds.has(place.id)
|
||||
return (
|
||||
<div
|
||||
<MemoPlaceRow
|
||||
key={place.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: 'grab',
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
place={place}
|
||||
category={cat}
|
||||
isSelected={isSelected}
|
||||
isPlanned={isPlanned}
|
||||
inDay={inDay}
|
||||
isChecked={isChecked}
|
||||
selectMode={selectMode}
|
||||
selectedDayId={selectedDayId}
|
||||
canEditPlaces={canEditPlaces}
|
||||
isMobile={isMobile}
|
||||
t={t}
|
||||
onPlaceClick={onPlaceClick}
|
||||
onContextMenu={openContextMenu}
|
||||
onAssignToDay={onAssignToDay}
|
||||
toggleSelected={toggleSelected}
|
||||
setDayPickerPlace={setDayPickerPlace}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
@@ -602,6 +756,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
onClose={() => setPendingDeleteIds(null)}
|
||||
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
|
||||
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { fetchWeather } from '../../services/weatherQueue'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user