mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9626fce9 | |||
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 | |||
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 |
@@ -366,10 +366,10 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
}
|
||||
@@ -487,6 +487,20 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
||||
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||
// flights + import are added with the trip-planner import (P2)
|
||||
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||
import: (tripId: number, flightIds: string[]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
@@ -559,8 +573,10 @@ export const mapsApi = {
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigation } from 'lucide-react'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
|
||||
/**
|
||||
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
|
||||
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
||||
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
||||
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||
* so its height and transparency match the POI pill exactly.
|
||||
*/
|
||||
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setBearing(map.getBearing())
|
||||
update()
|
||||
map.on('rotate', update)
|
||||
return () => { map.off('rotate', update) }
|
||||
}, [map])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
|
||||
aria-label="Reset north"
|
||||
className="text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
onMapReady?: (map: mapboxgl.Map | null) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
@@ -167,6 +169,7 @@ export function MapViewGL({
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
onMapReady,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -186,10 +189,15 @@ export function MapViewGL({
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// Single reusable hover popup (name/category/address card) shared by planned
|
||||
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
onMapReadyRef.current = onMapReady
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
@@ -212,6 +220,16 @@ export function MapViewGL({
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
offset: 18,
|
||||
maxWidth: '240px',
|
||||
className: 'trek-map-popup',
|
||||
})
|
||||
// Hand the map out so the trip planner can render its own compass pill next to
|
||||
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
||||
onMapReadyRef.current?.(map)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
@@ -357,6 +375,8 @@ export function MapViewGL({
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||
onMapReadyRef.current?.(null)
|
||||
if (reservationOverlayRef.current) {
|
||||
reservationOverlayRef.current.destroy()
|
||||
reservationOverlayRef.current = null
|
||||
@@ -430,6 +450,10 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
||||
// recreated under the pointer (e.g. when its photo streams in) never fires
|
||||
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
||||
popupRef.current?.remove()
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
@@ -450,6 +474,12 @@ export function MapViewGL({
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([place.lng, place.lat])
|
||||
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
||||
.addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
@@ -471,11 +501,15 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
el.title = poi.name
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
@@ -7,6 +7,8 @@ interface Props {
|
||||
active: Set<string>
|
||||
onToggle: (key: string) => void
|
||||
loadingKeys?: Set<string>
|
||||
/** categories whose last fetch failed → show a retry affordance */
|
||||
errorKeys?: Set<string>
|
||||
/** true when the map moved since the last search → offer "search this area" */
|
||||
moved?: boolean
|
||||
onSearchArea?: () => void
|
||||
@@ -15,8 +17,9 @@ interface Props {
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||
|
||||
const frosted: React.CSSProperties = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
aria-label={t(cat.labelKey)}
|
||||
className={on ? '' : 'text-content-muted'}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
) : (
|
||||
<cat.Icon size={16} strokeWidth={2} />
|
||||
)}
|
||||
{on && !loading && errorKeys?.has(cat.key) && (
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
|
||||
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{moved && active.size > 0 && (
|
||||
{(moved || anyError) && active.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchArea}
|
||||
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||
color: anyError ? '#ef4444' : undefined,
|
||||
...frosted,
|
||||
}}
|
||||
>
|
||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
||||
{anyError
|
||||
? <AlertTriangle size={13} strokeWidth={2.4} />
|
||||
: <RotateCw size={13} strokeWidth={2.4} />}
|
||||
{t('poi.searchThisArea')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
|
||||
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
|
||||
// no equivalent, so these produce the same card as an HTML string for a
|
||||
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
|
||||
|
||||
type PlaceWithCategory = Place & {
|
||||
category_color?: string | null
|
||||
category_icon?: string | null
|
||||
category_name?: string | null
|
||||
}
|
||||
|
||||
function esc(s: string | null | undefined): string {
|
||||
if (!s) return ''
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Render a lucide category icon to an inline SVG string in the given colour.
|
||||
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
|
||||
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
|
||||
// into an <img src> — everything else is a fetch seed, not a displayable URL.
|
||||
function isDisplayablePhoto(url: string | null | undefined): url is string {
|
||||
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
|
||||
}
|
||||
|
||||
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
|
||||
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||
|
||||
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
|
||||
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
|
||||
const img = isDisplayablePhoto(photoUrl)
|
||||
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
|
||||
: ''
|
||||
const category =
|
||||
place.category_name && place.category_icon
|
||||
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
|
||||
: ''
|
||||
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
|
||||
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
|
||||
}
|
||||
|
||||
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
|
||||
export function buildPoiPopupHtml(poi: Poi): string {
|
||||
const cat = POI_CATEGORY_BY_KEY[poi.category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
|
||||
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
|
||||
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
|
||||
return `${CARD_OPEN}${head}${address}</div>`
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import type { Poi } from './poiCategories'
|
||||
|
||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||
|
||||
// A request we cancelled on purpose (newer search superseded it) — not a failure.
|
||||
function isAbortError(err: unknown): boolean {
|
||||
const e = err as { name?: string; code?: string } | null
|
||||
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||
@@ -15,12 +21,18 @@ export function usePoiExplore() {
|
||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||
const [moved, setMoved] = useState(false)
|
||||
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
|
||||
// the pill can offer a retry instead of looking like "no places here".
|
||||
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const bboxRef = useRef<Bbox | null>(null)
|
||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||
// completions) can check whether a category is still wanted.
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
// One in-flight AbortController per category, so re-toggling / re-searching
|
||||
// cancels the previous (possibly slow) Overpass request instead of racing it.
|
||||
const abortRef = useRef<Record<string, AbortController>>({})
|
||||
|
||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -28,19 +40,41 @@ export function usePoiExplore() {
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
|
||||
if (on === prev.has(key)) return prev
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(key); else next.delete(key)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||
abortRef.current[key]?.abort()
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current[key] = ctrl
|
||||
setLoading(key, true)
|
||||
setError(key, false)
|
||||
try {
|
||||
const res = await mapsApi.pois(key, bbox)
|
||||
const res = await mapsApi.pois(key, bbox, ctrl.signal)
|
||||
// Drop the result if the user toggled this category off while the (slow)
|
||||
// Overpass request was in flight — otherwise stale results re-appear.
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// A superseded request was aborted on purpose — leave its state untouched
|
||||
// so the newer request owns the spinner and results.
|
||||
if (isAbortError(err)) return
|
||||
// A real failure (every Overpass mirror down/timed out): surface it instead
|
||||
// of a silent empty so the user can retry rather than assume "no places".
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||
if (activeRef.current.has(key)) setError(key, true)
|
||||
} finally {
|
||||
setLoading(key, false)
|
||||
// Only the latest controller for this key clears the spinner; a superseded
|
||||
// one must not, or it would hide the newer request's in-flight state.
|
||||
if (abortRef.current[key] === ctrl) {
|
||||
setLoading(key, false)
|
||||
delete abortRef.current[key]
|
||||
}
|
||||
}
|
||||
}, [setLoading])
|
||||
}, [setLoading, setError])
|
||||
|
||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||
bboxRef.current = bbox
|
||||
@@ -53,6 +87,11 @@ export function usePoiExplore() {
|
||||
const toggle = useCallback((key: string) => {
|
||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||
setMoved(false)
|
||||
setErrorKeys(new Set())
|
||||
// Switching to another category (or turning off) — cancel any in-flight
|
||||
// fetches so their results can't land after the selection changed.
|
||||
Object.values(abortRef.current).forEach(c => c.abort())
|
||||
abortRef.current = {}
|
||||
if (isOnlyActive) {
|
||||
setActive(new Set())
|
||||
setByCat({})
|
||||
@@ -72,5 +111,5 @@ export function usePoiExplore() {
|
||||
|
||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||
|
||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
||||
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
||||
}
|
||||
|
||||
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||
</div>
|
||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
|
||||
'#14b8a6', // teal
|
||||
]
|
||||
|
||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||
|
||||
// A category's first item is seeded with this sentinel because the server
|
||||
// rejects empty names. Treat it as a placeholder in the UI.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { Plane, X, Check } from 'lucide-react'
|
||||
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { airtrailApi, reservationsApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
interface AirTrailImportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
||||
function fmtDate(d: string | null, locale: string): string {
|
||||
if (!d) return ''
|
||||
try {
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
} catch {
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const reservations = useTripStore(s => s.reservations)
|
||||
const loadReservations = useTripStore(s => s.loadReservations)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [flights, setFlights] = useState<AirtrailFlight[]>([])
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||
|
||||
// AirTrail flight ids already linked to a reservation in this trip.
|
||||
const importedIds = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const r of reservations) {
|
||||
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
|
||||
}
|
||||
return set
|
||||
}, [reservations])
|
||||
|
||||
const inRange = (f: AirtrailFlight): boolean =>
|
||||
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setError('')
|
||||
setSelected(new Set())
|
||||
setLoading(true)
|
||||
airtrailApi
|
||||
.flights()
|
||||
.then((d: { flights: AirtrailFlight[] }) => {
|
||||
const list = d.flights ?? []
|
||||
setFlights(list)
|
||||
// Pre-select the flights that fall inside the trip and aren't imported yet.
|
||||
const pre = new Set<string>()
|
||||
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
|
||||
setSelected(pre)
|
||||
})
|
||||
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
|
||||
.finally(() => setLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
const { during, others } = useMemo(() => {
|
||||
const during: AirtrailFlight[] = []
|
||||
const others: AirtrailFlight[] = []
|
||||
for (const f of flights) (inRange(f) ? during : others).push(f)
|
||||
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
|
||||
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flights, trip?.start_date, trip?.end_date])
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => { onClose() }
|
||||
|
||||
const handleImport = async () => {
|
||||
const ids = [...selected].filter(id => !importedIds.has(id))
|
||||
if (ids.length === 0 || importing) return
|
||||
setImporting(true)
|
||||
setError('')
|
||||
try {
|
||||
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
|
||||
await loadReservations(tripId)
|
||||
|
||||
const imported = result.imported ?? []
|
||||
if (imported.length > 0) {
|
||||
pushUndo?.(t('reservations.airtrail.undo'), async () => {
|
||||
const linked = useTripStore.getState().reservations.filter(
|
||||
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
|
||||
)
|
||||
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
|
||||
await loadReservations(tripId)
|
||||
})
|
||||
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
|
||||
}
|
||||
|
||||
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
|
||||
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
|
||||
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
|
||||
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const renderFlight = (f: AirtrailFlight) => {
|
||||
const already = importedIds.has(f.id)
|
||||
const isSelected = selected.has(f.id)
|
||||
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}`
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => !already && toggle(f.id)}
|
||||
disabled={already}
|
||||
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
|
||||
style={{
|
||||
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
|
||||
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
|
||||
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: isSelected || already ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
|
||||
</span>
|
||||
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{already && (
|
||||
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
|
||||
{t('reservations.airtrail.alreadyImported')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<Plane size={16} color="#3b82f6" />
|
||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.airtrail.title')}
|
||||
</div>
|
||||
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{loading && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && flights.length === 0 && !error && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('reservations.airtrail.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && during.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
|
||||
{t('reservations.airtrail.duringTrip')}
|
||||
</div>
|
||||
{during.map(renderFlight)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && others.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
|
||||
{t('reservations.airtrail.otherFlights')}
|
||||
</div>
|
||||
{others.map(renderFlight)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<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={selectableCount === 0 || importing}
|
||||
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
|
||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||
{ id: 'Bookmark', Icon: Bookmark },
|
||||
{ id: 'Utensils', Icon: Utensils },
|
||||
{ id: 'Wine', Icon: Wine },
|
||||
{ id: 'ParkingSquare', Icon: ParkingSquare },
|
||||
{ id: 'Fuel', Icon: Fuel },
|
||||
{ id: 'Footprints', Icon: Footprints },
|
||||
{ id: 'Mountain', Icon: Mountain },
|
||||
{ id: 'Waves', Icon: Waves },
|
||||
{ id: 'Sun', Icon: Sun },
|
||||
{ id: 'Umbrella', Icon: Umbrella },
|
||||
{ id: 'Music', Icon: Music },
|
||||
{ id: 'Landmark', Icon: Landmark },
|
||||
{ id: 'Gift', Icon: Gift },
|
||||
]
|
||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||
|
||||
@@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||
]
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assigns = {
|
||||
'10': [
|
||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
|
||||
})} />)
|
||||
// Days are expanded by default, so route tools must be visible even with no selected day
|
||||
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onReorder = vi.fn().mockResolvedValue(undefined)
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
|
||||
]
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assigns = {
|
||||
'10': [
|
||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
|
||||
})} />)
|
||||
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||
await user.click(optimizeBtn)
|
||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
initialScrollTop?: number
|
||||
onScrollTopChange?: (top: number) => void
|
||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||
showRouteToolsWhenExpanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
} = props
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -742,9 +745,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||
if (!dayId) return
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
@@ -764,7 +767,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const day = days.find(d => d.id === dayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
: {}
|
||||
@@ -781,10 +784,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||
}
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
await onReorder(dayId, result.map(a => a.id))
|
||||
const usedHotel = !!(anchors.start || anchors.end)
|
||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
const capturedDayId = dayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
@@ -901,6 +904,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1047,6 +1051,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -2096,7 +2101,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
@@ -2112,7 +2117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -2141,7 +2146,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{routeInfo && (
|
||||
{isSelected && routeInfo && (
|
||||
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span className="text-content-faint">·</span>
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
/>
|
||||
<textarea
|
||||
value={ui.time}
|
||||
maxLength={150}
|
||||
maxLength={250}
|
||||
rows={3}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
||||
className="text-content"
|
||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||
|
||||
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{reorderOpen && (
|
||||
<DayReorderPopup
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<DayReorderPopup
|
||||
isOpen={reorderOpen}
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
isOpen: boolean
|
||||
days: Day[]
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
||||
* the single surface for ordering. Reorders are applied optimistically by the
|
||||
* store, so the list reflects each move immediately.
|
||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||
* so the list reflects each move immediately.
|
||||
*/
|
||||
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
|
||||
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
||||
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||
} as const
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* outside-click catcher */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('dayplan.reorderTitle')}
|
||||
size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddDay}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
|
||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</p>
|
||||
|
||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
||||
borderRadius: 8, marginTop: 2,
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||
borderRadius: 9,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||
* render over the form fields. */
|
||||
|
||||
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||
const DUP_COORD_TOLERANCE = 0.0001
|
||||
function findDuplicatePlace(
|
||||
form: PlaceFormData,
|
||||
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
||||
): { name?: string | null } | null {
|
||||
const name = (form.name || '').trim().toLowerCase()
|
||||
const gid = (form.google_place_id || '').trim()
|
||||
const lat = form.lat ? parseFloat(form.lat) : null
|
||||
const lng = form.lng ? parseFloat(form.lng) : null
|
||||
for (const p of places || []) {
|
||||
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||
if (
|
||||
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||
) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const {
|
||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(DEFAULT_FORM)
|
||||
}
|
||||
setPendingFiles([])
|
||||
setDuplicateWarning(null)
|
||||
}, [place, prefillCoords, isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
@@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast.error(t('places.nameRequired'))
|
||||
return
|
||||
}
|
||||
// #1152: only for new places, and only on the first attempt — a second click
|
||||
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
||||
if (!place && !duplicateWarning) {
|
||||
const dup = findDuplicatePlace(form, places)
|
||||
if (dup) {
|
||||
const dupName = dup.name || form.name
|
||||
setDuplicateWarning(dupName)
|
||||
toast.warning(t('places.duplicateExists', { name: dupName }))
|
||||
return
|
||||
}
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
@@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
} = S
|
||||
return (
|
||||
<Modal
|
||||
@@ -463,7 +503,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
disabled={isSaving || hasTimeError}
|
||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||
>
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function ListImportModal(S: SidebarState) {
|
||||
const {
|
||||
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
||||
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
} = S
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
|
||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{canEnrichImport && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
|
||||
</div>
|
||||
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||
|
||||
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{t('reservations.needsReview')}
|
||||
</span>
|
||||
) : null}
|
||||
{r.external_source === 'airtrail' ? (
|
||||
<span
|
||||
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
|
||||
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
|
||||
>
|
||||
<Plane size={11} />
|
||||
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span className="text-content" style={{
|
||||
@@ -472,6 +482,8 @@ interface ReservationsPanelProps {
|
||||
onAdd: () => void
|
||||
onImport?: () => void
|
||||
bookingImportAvailable?: boolean
|
||||
onAirTrailImport?: () => void
|
||||
airTrailAvailable?: boolean
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
@@ -479,7 +491,7 @@ interface ReservationsPanelProps {
|
||||
addManualKey?: string
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
@@ -602,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onAirTrailImport && airTrailAvailable && (
|
||||
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
|
||||
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
title={t('reservations.airtrail.title')}
|
||||
>
|
||||
<Plane size={14} strokeWidth={2} />
|
||||
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
|
||||
@@ -77,9 +77,10 @@ interface WaypointForm {
|
||||
depTime: string
|
||||
airline: string
|
||||
flight_number: string
|
||||
seat: string
|
||||
}
|
||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -197,6 +198,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -206,6 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||
dep.airline = meta.airline ?? ''
|
||||
dep.flight_number = meta.flight_number ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||
arr.airport = airportFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||
@@ -271,6 +274,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
to: next.airport!.iata,
|
||||
...(w.airline ? { airline: w.airline } : {}),
|
||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||
...(w.seat ? { seat: w.seat } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
@@ -279,6 +283,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
})
|
||||
}
|
||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
@@ -501,7 +506,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||
@@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
export interface PlacesSidebarProps {
|
||||
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
||||
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
||||
const isNaverListImportEnabled = true
|
||||
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [listImportUrl, setListImportUrl] = useState('')
|
||||
const [listImportLoading, setListImportLoading] = useState(false)
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
const [listImportEnrich, setListImportEnrich] = useState(false)
|
||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
|
||||
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
setListImportLoading(true)
|
||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||
try {
|
||||
const enrich = listImportEnrich && canEnrichImport
|
||||
const result = provider === 'google'
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
scrollContainerRef, onScrollTopChange,
|
||||
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
||||
listImportLoading, listImportProvider, setListImportProvider,
|
||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Plane, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { airtrailApi } from '../../api/client'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
|
||||
/**
|
||||
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
||||
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
||||
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
||||
* a status badge. The key is stored encrypted and never prefilled.
|
||||
*/
|
||||
export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
airtrailApi
|
||||
.getSettings()
|
||||
.then(d => {
|
||||
setUrl(d.url || '')
|
||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||
setConnected(!!d.connected)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// Send the key only when the user typed a new one — never prefilled, so a blank
|
||||
// field means "keep the stored key".
|
||||
const keyPayload = (): { apiKey?: string } => {
|
||||
const k = apiKey.trim()
|
||||
return k ? { apiKey: k } : {}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||
setConnected(!!status.connected)
|
||||
setApiKey('')
|
||||
if (d?.warning) toast.warning(d.warning)
|
||||
else toast.success(t('settings.airtrail.toast.saved'))
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true)
|
||||
try {
|
||||
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||
setConnected(!!d.connected)
|
||||
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
|
||||
else toast.error(d.error || t('settings.airtrail.test.failed'))
|
||||
} catch {
|
||||
toast.error(t('settings.airtrail.test.failed'))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
||||
|
||||
return (
|
||||
<Section title={t('settings.airtrail.title')} icon={Plane}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="https://airtrail.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !canSave}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || loading || !url.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
>
|
||||
{testing ? (
|
||||
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
) : (
|
||||
<Plane className="w-4 h-4" />
|
||||
)}
|
||||
{t('settings.airtrail.test.button')}
|
||||
</button>
|
||||
{connected ? (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('settings.airtrail.connected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||
{t('settings.airtrail.notConnected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
|
||||
import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
@@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||
<McpTokenModals {...S} />
|
||||
<OAuthClientModals {...S} />
|
||||
@@ -109,6 +111,7 @@ function useIntegrations() {
|
||||
const toast = useToast()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
@@ -289,7 +292,7 @@ function useIntegrations() {
|
||||
|
||||
|
||||
return {
|
||||
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
const [desc, setDesc] = useState(item.description || '')
|
||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||
const [category, setCategory] = useState(item.category || '')
|
||||
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||
const [priority, setPriority] = useState(item.priority || 0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(String(v))}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c,
|
||||
label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
{addingCategory ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||
placeholder={t('todo.newCategory')}
|
||||
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
/>
|
||||
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(String(v))}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
...(category && !categories.includes(category) ? [{
|
||||
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||
}] : []),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||
title={t('todo.newCategory')}
|
||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { airtrailApi } from '../api/client'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
|
||||
/**
|
||||
* Resolves whether the current user can use AirTrail in a trip: the addon must
|
||||
* be enabled globally AND the user must have a working connection. Drives the
|
||||
* "AirTrail Import/Sync" button visibility in the Transport panel.
|
||||
*/
|
||||
export function useAirtrailConnection() {
|
||||
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!airtrailEnabled) {
|
||||
setConnected(false)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
airtrailApi
|
||||
.status()
|
||||
.then(d => { if (!cancelled) setConnected(!!d.connected) })
|
||||
.catch(() => { if (!cancelled) setConnected(false) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [airtrailEnabled])
|
||||
|
||||
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
|
||||
}
|
||||
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Mapbox GL hover popup — the name/category/address card on marker hover.
|
||||
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
||||
onto the popup never steals the marker's mouseleave and causes flicker. */
|
||||
.trek-map-popup { pointer-events: none; }
|
||||
.trek-map-popup .mapboxgl-popup-content {
|
||||
padding: 7px 10px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.trek-map-popup .mapboxgl-popup-tip {
|
||||
border-top-color: #fff;
|
||||
border-bottom-color: #fff;
|
||||
border-left-color: #fff;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
.atlas-tooltip {
|
||||
background: rgba(10, 10, 20, 0.6) !important;
|
||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
|
||||
await user.click(archiveButtons[0]);
|
||||
|
||||
// Switch to the archive filter segment
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Switch to the archive filter
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
|
||||
});
|
||||
|
||||
// Archive filter reveals the archived trip
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||
});
|
||||
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('Archive'));
|
||||
await user.click(screen.getByText('Archived'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import {
|
||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, SlidersHorizontal, Ticket, X,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<div className="sec-tools">
|
||||
<div className="seg">
|
||||
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||
</div>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||
</button>
|
||||
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||
<SlidersHorizontal size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||
placeholder="000000 or XXXX-XXXX"
|
||||
required
|
||||
autoFocus
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -17,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
@@ -187,6 +189,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
@@ -206,6 +209,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} = useTripPlanner()
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
@@ -308,11 +312,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
onMapReady={setGlMap}
|
||||
/>
|
||||
|
||||
{poiPillEnabled && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
||||
{poiPillEnabled && (
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
)}
|
||||
{glMap && <MapCompassPill map={glMap} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -608,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -628,6 +636,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||
airTrailAvailable={airTrailAvailable}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
@@ -697,6 +707,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import Modal from '../../components/shared/Modal'
|
||||
import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
handleCreateUser, handleSaveUser,
|
||||
} = admin
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
||||
const [showEditPw, setShowEditPw] = React.useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCreatePw ? 'text' : 'password'}
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreatePw(v => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEditPw ? 'text' : 'password'}
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditPw(v => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
|
||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||
const {
|
||||
serverTimezone, hour12, currentUser,
|
||||
hour12, currentUser,
|
||||
users, isLoading,
|
||||
setShowCreateUser,
|
||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,8 @@ export function useSettings() {
|
||||
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -16,6 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||
|
||||
/**
|
||||
@@ -140,6 +141,18 @@ export function useTripPlanner() {
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
||||
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
||||
const { available: airTrailAvailable } = useAirtrailConnection()
|
||||
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
|
||||
// Pull this user's AirTrail edits as soon as they open the trip, so changes
|
||||
// made in AirTrail show up without waiting for the background poll.
|
||||
const airtrailSyncedRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
|
||||
airtrailSyncedRef.current = tripId
|
||||
airtrailApi.sync()
|
||||
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
|
||||
.catch(() => {})
|
||||
}, [airTrailAvailable, tripId, tripActions])
|
||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
@@ -666,6 +679,7 @@ export function useTripPlanner() {
|
||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
|
||||
@@ -378,8 +378,12 @@
|
||||
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
|
||||
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
|
||||
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
|
||||
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
|
||||
floating alone at the far left. */
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
|
||||
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
|
||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
|
||||
.trek-dash .trip-card {
|
||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
@@ -526,6 +530,9 @@
|
||||
|
||||
/* Hero — immersive cover, title only (the pass is its own card below) */
|
||||
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
|
||||
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
|
||||
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
|
||||
.trek-dash .hero-trip:hover img.bg { transform: none; }
|
||||
.trek-dash .hero-content { padding: 18px; }
|
||||
/* the page already opens with the notification/profile strip, trim its top gap */
|
||||
.trek-dash .page { padding-top: 4px; }
|
||||
@@ -580,25 +587,33 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* Touch devices have no hover — keep the edit/copy/archive/delete actions
|
||||
visible at all times instead of revealing them on hover. */
|
||||
.trek-dash .trip-actions { opacity: 1; }
|
||||
|
||||
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||
desktop list row uses a 520px cover, which overflowed the phone width: the
|
||||
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
||||
height left a white strip beneath it. Use a fitting cover that stretches to
|
||||
the row, and show just the title + dates (the counts live in grid view and
|
||||
on the trip itself). */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
||||
/* Mobile list row → stacked two-row: row 1 is a slim full-width cover banner
|
||||
(image + title overlay + status top-left), row 2 is just the date, centred.
|
||||
The counts stay grid-view-only on mobile. */
|
||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
|
||||
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
|
||||
.trek-dash .trips.list-view .trip-name {
|
||||
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
|
||||
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
|
||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
|
||||
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
|
||||
.trek-dash .trips.list-view .trip-meta { display: none; }
|
||||
|
||||
/* Tools — stacked full-width cards (mockup) */
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
|
||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ADDON_IDS = {
|
||||
ATLAS: 'atlas',
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
AIRTRAIL: 'airtrail',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
@@ -2460,6 +2460,35 @@ function runMigrations(db: Database.Database): void {
|
||||
if (after && after.region_code === row.region_code) del.run(row.id);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// AirTrail integration addon — disabled by default (opt-in). Per-user connection
|
||||
// lives in Settings → Integrations; this row is only the admin-level global toggle.
|
||||
try {
|
||||
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run('airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14);
|
||||
} catch (err: any) {
|
||||
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// AirTrail per-user connection (mirrors the Immich integration columns).
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
() => {
|
||||
// AirTrail flight linkage on reservations (#214) — lets a TREK transport
|
||||
// remember its AirTrail origin so the two-way sync can match + update it.
|
||||
// sync_enabled flips to 0 when the AirTrail flight is deleted (row kept).
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_source TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_id TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_synced_at TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec("ALTER TABLE reservations ADD COLUMN external_hash TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
// NULLs compare distinct in SQLite, so non-linked reservations don't collide.
|
||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)");
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -98,6 +98,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
@@ -79,6 +79,7 @@ const onListen = () => {
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
scheduler.startAirTrailSync();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -78,10 +78,12 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
currency: z.string().length(3).optional(),
|
||||
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
|
||||
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, title, description, start_date, end_date, currency }) => {
|
||||
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
|
||||
@@ -95,7 +97,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
|
||||
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
|
||||
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
|
||||
}
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
|
||||
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user');
|
||||
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
|
||||
return ok({ trip: updatedTrip });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { CollabModule } from './collab/collab.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { MemoriesModule } from './memories/memories.module';
|
||||
import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
@@ -41,10 +42,11 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
|
||||
/**
|
||||
* Root NestJS module for the incremental migration. Domain modules
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
* (weather, notifications, integrations, ...) get registered here as they are
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/**
|
||||
* Gates the AirTrail integration routes on the global `airtrail` addon. When the
|
||||
* admin has it disabled the whole group answers 404. Declared before the
|
||||
* JwtAuthGuard so the addon check wins over the 401 (same ordering as the
|
||||
* Journey addon gate).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AirtrailAddonGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
|
||||
throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AirtrailAddonGuard } from './airtrail-addon.guard';
|
||||
import { airtrailImportSchema, type AirtrailImport, type AirtrailImportResult } from '@trek/shared';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import { importAirtrailFlights } from '../../services/airtrail/airtrailImport';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/airtrail — turn selected AirTrail
|
||||
* flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The
|
||||
* flights are re-fetched server-side with the caller's own key.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/reservations/import')
|
||||
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
|
||||
export class AirtrailImportController {
|
||||
private requireEdit(tripId: string, user: User): void {
|
||||
const trip = verifyTripAccess(tripId, user.id);
|
||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
if (!checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) {
|
||||
throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('airtrail')
|
||||
async importAirtrail(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body(new ZodValidationPipe(airtrailImportSchema)) body: AirtrailImport,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
): Promise<AirtrailImportResult> {
|
||||
this.requireEdit(tripId, user);
|
||||
try {
|
||||
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { User } from '../../types';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AirtrailAddonGuard } from './airtrail-addon.guard';
|
||||
import { getClientIp } from '../../services/auditLog';
|
||||
import { airtrailSettingsSchema, type AirtrailSettings } from '@trek/shared';
|
||||
import {
|
||||
getConnectionSettings,
|
||||
getConnectionStatus,
|
||||
getFlightsForPicker,
|
||||
saveSettings,
|
||||
testConnection,
|
||||
} from '../../services/airtrail/airtrailService';
|
||||
import { runAirtrailSyncForUser } from '../../services/airtrail/airtrailSync';
|
||||
|
||||
/**
|
||||
* /api/integrations/airtrail — per-user AirTrail connection (#214).
|
||||
*
|
||||
* `status` and `test` answer 200 even on failure (the service shapes
|
||||
* `{ connected: false, error }`); `settings` PUT validates with a 400. The API
|
||||
* key is never echoed — `getSettings` returns it masked. The route group is
|
||||
* gated on the `airtrail` addon (404 when disabled).
|
||||
*/
|
||||
@Controller('api/integrations/airtrail')
|
||||
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
|
||||
export class AirtrailController {
|
||||
@Get('settings')
|
||||
getSettings(@CurrentUser() user: User) {
|
||||
return getConnectionSettings(user.id);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
async putSettings(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const result = await saveSettings(
|
||||
user.id,
|
||||
body.url,
|
||||
body.apiKey,
|
||||
!!body.allowInsecureTls,
|
||||
getClientIp(req),
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new HttpException({ error: result.error }, 400);
|
||||
}
|
||||
return result.warning ? { success: true, warning: result.warning } : { success: true };
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
getStatus(@CurrentUser() user: User) {
|
||||
return getConnectionStatus(user.id);
|
||||
}
|
||||
|
||||
@Get('flights')
|
||||
async flights(@CurrentUser() user: User) {
|
||||
try {
|
||||
return { flights: await getFlightsForPicker(user.id) };
|
||||
} catch (err: any) {
|
||||
throw new HttpException({ error: err?.message || 'Could not load AirTrail flights' }, err?.status === 400 ? 400 : 502);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull this user's AirTrail edits into their linked reservations on demand. */
|
||||
@Post('sync')
|
||||
@HttpCode(200)
|
||||
sync(@CurrentUser() user: User) {
|
||||
return runAirtrailSyncForUser(user.id);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@HttpCode(200)
|
||||
test(
|
||||
@CurrentUser() user: User,
|
||||
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
|
||||
) {
|
||||
return testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AirtrailController } from './airtrail.controller';
|
||||
import { AirtrailImportController } from './airtrail-import.controller';
|
||||
|
||||
/**
|
||||
* AirTrail integration domain. The connection lives under
|
||||
* /api/integrations/airtrail; the flight import is trip-scoped under
|
||||
* /api/trips/:tripId/reservations/import/airtrail. Business logic lives in
|
||||
* services/airtrail/* (plain functions over better-sqlite3).
|
||||
*/
|
||||
@Module({
|
||||
controllers: [AirtrailController, AirtrailImportController],
|
||||
})
|
||||
export class AirtrailModule {}
|
||||
@@ -55,12 +55,17 @@ export class MapsController {
|
||||
@CurrentUser() user: User,
|
||||
@Body('query') query: unknown,
|
||||
@Query('lang') lang?: string,
|
||||
@Body('locationBias') locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<MapsSearchResult> {
|
||||
if (!query) {
|
||||
throw new HttpException({ error: 'Search query is required' }, 400);
|
||||
}
|
||||
// Optional bias toward a coordinate (lat/lng[/radius]); improves foreign-region queries.
|
||||
if (locationBias && !(Number.isFinite(locationBias.lat) && Number.isFinite(locationBias.lng))) {
|
||||
throw new HttpException({ error: 'Invalid locationBias: lat and lng must be finite numbers' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.search(user.id, query as string, lang);
|
||||
return await this.maps.search(user.id, query as string, lang, locationBias);
|
||||
} catch (err: unknown) {
|
||||
console.error('Maps search error:', err);
|
||||
throw toHttpException(err, 'Search error', 500);
|
||||
|
||||
@@ -56,8 +56,8 @@ export class MapsService {
|
||||
return this.isSettingDisabled('places_photos_enabled');
|
||||
}
|
||||
|
||||
search(userId: number, query: string, lang?: string): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang) as Promise<MapsSearchResult>;
|
||||
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
|
||||
|
||||
@@ -163,27 +163,30 @@ export class PlacesController {
|
||||
}
|
||||
|
||||
@Post('import/google-list')
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, socketId);
|
||||
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('google', user, tripId, url, enrich, socketId);
|
||||
}
|
||||
|
||||
@Post('import/naver-list')
|
||||
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, socketId);
|
||||
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.importList('naver', user, tripId, url, enrich, socketId);
|
||||
}
|
||||
|
||||
/** Shared google/naver list import — identical flow, different provider + error string. */
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
|
||||
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new HttpException({ error: 'URL is required' }, 400);
|
||||
}
|
||||
// Opt-in: re-resolve each imported place via the Places API to fill in
|
||||
// photo / address / website / phone and persist a google_place_id (#886).
|
||||
const opts = { enrich: parseBool(enrich, false), userId: user.id };
|
||||
const label = provider === 'google' ? 'Google' : 'Naver';
|
||||
try {
|
||||
const result = provider === 'google'
|
||||
? await this.places.importGoogleList(tripId, url)
|
||||
: await this.places.importNaverList(tripId, url);
|
||||
? await this.places.importGoogleList(tripId, url, opts)
|
||||
: await this.places.importNaverList(tripId, url, opts);
|
||||
if ('error' in result) {
|
||||
throw new HttpException({ error: result.error }, result.status);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export class PlacesService {
|
||||
return svc.importMapFile(tripId, buffer, filename, opts);
|
||||
}
|
||||
|
||||
importGoogleList(tripId: string, url: string) {
|
||||
return svc.importGoogleList(tripId, url);
|
||||
importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
|
||||
return svc.importGoogleList(tripId, url, opts);
|
||||
}
|
||||
|
||||
importNaverList(tripId: string, url: string) {
|
||||
return svc.importNaverList(tripId, url);
|
||||
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
|
||||
return svc.importNaverList(tripId, url, opts);
|
||||
}
|
||||
|
||||
searchImage(tripId: string, id: string, userId: number) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { User } from '../../types';
|
||||
import { ReservationsService } from './reservations.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync';
|
||||
|
||||
type ReservationBody = Record<string, unknown> & {
|
||||
title?: string;
|
||||
@@ -115,6 +116,11 @@ export class ReservationsController {
|
||||
const cur = current as { title: string; type?: string };
|
||||
this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId);
|
||||
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
|
||||
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
|
||||
// under the importer's credentials — see airtrailSync). #214
|
||||
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
|
||||
void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
|
||||
}
|
||||
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
|
||||
return { reservation };
|
||||
}
|
||||
|
||||
+27
-1
@@ -334,6 +334,31 @@ function startTrekPhotoCacheCleanup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// AirTrail sync: poll connected instances on an interval and reconcile linked
|
||||
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
|
||||
// runAirtrailSync, so toggling the addon takes effect without a restart.
|
||||
let airtrailSyncTask: ScheduledTask | null = null;
|
||||
|
||||
function startAirTrailSync(): void {
|
||||
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
|
||||
|
||||
const { db } = require('./db/database');
|
||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10);
|
||||
const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
|
||||
|
||||
airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => {
|
||||
try {
|
||||
const { runAirtrailSync } = require('./services/airtrail/airtrailSync');
|
||||
await runAirtrailSync();
|
||||
} catch (err: unknown) {
|
||||
logError(`AirTrail sync tick failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
@@ -341,6 +366,7 @@ function stop(): void {
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail).
|
||||
* This is the ONLY place that talks to a user's AirTrail instance.
|
||||
*
|
||||
* Verified against AirTrail source:
|
||||
* - Auth: `Authorization: Bearer <key>`; a key maps to exactly one user.
|
||||
* - GET /api/flight/list — defaults to scope=mine. We NEVER send a scope
|
||||
* param so the key only ever returns its owner's own flights (isolation
|
||||
* holds even if an admin key is pasted).
|
||||
* - GET /api/flight/get/{id}
|
||||
* - POST /api/flight/save — `id` present => update, else create. seats[] is
|
||||
* required (>=1). A seat with userId '<USER_ID>' is attributed to the key
|
||||
* owner server-side, so we never need the caller's AirTrail user id.
|
||||
* - There is no webhook and no updated_at on a flight, so change detection is
|
||||
* snapshot-hash based (see airtrailSync).
|
||||
*/
|
||||
|
||||
const TIMEOUT_MS = 12000;
|
||||
|
||||
export interface AirtrailCreds {
|
||||
/** Instance origin without a trailing /api. */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
allowInsecureTls: boolean;
|
||||
}
|
||||
|
||||
export class AirtrailAuthError extends Error {
|
||||
constructor(message = 'AirTrail rejected the API key') {
|
||||
super(message);
|
||||
this.name = 'AirtrailAuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AirtrailRequestError extends Error {
|
||||
status?: number;
|
||||
constructor(message: string, status?: number) {
|
||||
super(message);
|
||||
this.name = 'AirtrailRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AirtrailAirport {
|
||||
id: number;
|
||||
icao: string | null;
|
||||
iata: string | null;
|
||||
name: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
tz: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
export interface AirtrailSeat {
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}
|
||||
|
||||
/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */
|
||||
export interface AirtrailNamedCode {
|
||||
id?: number;
|
||||
icao?: string | null;
|
||||
iata?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** A flight as returned by list/get (the fields TREK consumes). */
|
||||
export interface AirtrailFlightRaw {
|
||||
id: number;
|
||||
from: AirtrailAirport | null;
|
||||
to: AirtrailAirport | null;
|
||||
date: string | null;
|
||||
datePrecision: string | null;
|
||||
departure: string | null;
|
||||
arrival: string | null;
|
||||
airline: AirtrailNamedCode | null;
|
||||
flightNumber: string | null;
|
||||
aircraft: AirtrailNamedCode | null;
|
||||
aircraftReg: string | null;
|
||||
flightReason: string | null;
|
||||
note: string | null;
|
||||
seats: AirtrailSeat[];
|
||||
}
|
||||
|
||||
/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */
|
||||
export interface AirtrailSavePayload {
|
||||
id?: number;
|
||||
from: string;
|
||||
to: string;
|
||||
departure: string;
|
||||
departureTime?: string | null;
|
||||
arrival?: string | null;
|
||||
arrivalTime?: string | null;
|
||||
datePrecision?: string;
|
||||
airline?: string | null;
|
||||
flightNumber?: string | null;
|
||||
aircraft?: string | null;
|
||||
aircraftReg?: string | null;
|
||||
flightReason?: string | null;
|
||||
note?: string | null;
|
||||
seats: Array<{
|
||||
userId: string | null;
|
||||
guestName: string | null;
|
||||
seat: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClass: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
function apiBase(baseUrl: string): string {
|
||||
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
|
||||
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
|
||||
return origin + '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a
|
||||
* misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login
|
||||
* page) into an actionable message.
|
||||
*/
|
||||
async function parseJson<T>(resp: Response): Promise<T> {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new AirtrailRequestError(
|
||||
'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise<Response> {
|
||||
const url = apiBase(creds.baseUrl) + path;
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await safeFetch(
|
||||
url,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${creds.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS) as any,
|
||||
},
|
||||
{ rejectUnauthorized: !creds.allowInsecureTls },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail');
|
||||
}
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
throw new AirtrailAuthError();
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function listFlights(creds: AirtrailCreds): Promise<AirtrailFlightRaw[]> {
|
||||
const resp = await request(creds, '/flight/list', { method: 'GET' });
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp);
|
||||
return data.flights ?? [];
|
||||
}
|
||||
|
||||
export async function getFlight(creds: AirtrailCreds, id: number): Promise<AirtrailFlightRaw | null> {
|
||||
const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' });
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status);
|
||||
const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp);
|
||||
return data.flight ?? null;
|
||||
}
|
||||
|
||||
export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> {
|
||||
const resp = await request(creds, '/flight/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let msg = `AirTrail save failed (HTTP ${resp.status})`;
|
||||
try {
|
||||
const body = (await resp.json()) as { message?: string; errors?: unknown };
|
||||
if (body?.message) msg = body.message;
|
||||
else if (body?.errors) msg = JSON.stringify(body.errors);
|
||||
} catch {
|
||||
/* keep the generic message */
|
||||
}
|
||||
throw new AirtrailRequestError(msg, resp.status);
|
||||
}
|
||||
const data = await parseJson<{ id?: number }>(resp);
|
||||
return { id: data.id };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AirtrailImportResult } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { createReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import { AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
interface ExistingFlightRow {
|
||||
id: number;
|
||||
reservation_time: string | null;
|
||||
metadata: string | null;
|
||||
from_code: string | null;
|
||||
to_code: string | null;
|
||||
}
|
||||
|
||||
function depDate(t: string | null): string | null {
|
||||
return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** A loose "same physical flight" key: flight number + date, else route + date. */
|
||||
function softSignature(
|
||||
date: string | null,
|
||||
flightNumber: string | null,
|
||||
fromCode: string | null,
|
||||
toCode: string | null,
|
||||
): string | null {
|
||||
if (!date) return null;
|
||||
if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`;
|
||||
if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
|
||||
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
|
||||
*
|
||||
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
|
||||
* flight that looks like one already in the trip — e.g. the same flight another
|
||||
* member already imported from their own AirTrail — is skipped ('already-in-trip').
|
||||
* The server re-fetches the flights by id with the caller's own key, so the client
|
||||
* cannot inject arbitrary flight data.
|
||||
*/
|
||||
export async function importAirtrailFlights(
|
||||
tripId: string | number,
|
||||
userId: number,
|
||||
flightIds: string[],
|
||||
socketId: string | undefined,
|
||||
): Promise<AirtrailImportResult> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
|
||||
const wanted = new Set(flightIds.map(String));
|
||||
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
|
||||
|
||||
const result: AirtrailImportResult = { imported: [], skipped: [] };
|
||||
|
||||
const linkedIds = new Set(
|
||||
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
|
||||
external_id: string | null;
|
||||
}[])
|
||||
.map(r => r.external_id)
|
||||
.filter((v): v is string => !!v),
|
||||
);
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT r.id, r.reservation_time, r.metadata,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
|
||||
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
|
||||
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
|
||||
)
|
||||
.all(tripId) as ExistingFlightRow[];
|
||||
|
||||
const existingSigs = new Set<string>();
|
||||
for (const row of existing) {
|
||||
let fn: string | null = null;
|
||||
try {
|
||||
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
|
||||
} catch {
|
||||
/* malformed metadata — ignore */
|
||||
}
|
||||
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
|
||||
if (sig) existingSigs.add(sig);
|
||||
}
|
||||
|
||||
for (const flight of selected) {
|
||||
const fid = String(flight.id);
|
||||
if (linkedIds.has(fid)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-imported' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapFlightToReservation(flight);
|
||||
const sig = softSignature(
|
||||
depDate(mapped.reservation_time),
|
||||
(mapped.metadata.flight_number as string) ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
|
||||
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
|
||||
);
|
||||
if (sig && existingSigs.has(sig)) {
|
||||
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const { reservation } = createReservation(tripId, mapped as any);
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
|
||||
sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
|
||||
).run(fid, userId, canonicalHash(flight), now, reservation.id);
|
||||
|
||||
// Carry the linkage on the broadcast payload so members see the badge live.
|
||||
reservation.external_source = 'airtrail';
|
||||
reservation.external_id = fid;
|
||||
reservation.external_owner_user_id = userId;
|
||||
reservation.sync_enabled = 1;
|
||||
reservation.external_synced_at = now;
|
||||
|
||||
broadcast(tripId, 'reservation:created', { reservation }, socketId);
|
||||
if (sig) existingSigs.add(sig);
|
||||
linkedIds.add(fid);
|
||||
result.imported.push(fid);
|
||||
} catch (err) {
|
||||
console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err);
|
||||
result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
|
||||
/** Preferred display/lookup code for an airport. */
|
||||
function airportCode(a: AirtrailAirport | null): string | null {
|
||||
return a?.iata || a?.icao || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
||||
*/
|
||||
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||
return e?.icao || e?.iata || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local calendar date + clock time for an instant at a given IANA zone.
|
||||
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
|
||||
* `date`; the airport-local wall time is what TREK shows and files days by.
|
||||
*/
|
||||
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
|
||||
if (!iso) return { date: null, time: null };
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return { date: null, time: null };
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: tz || 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(d);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
|
||||
const date = `${get('year')}-${get('month')}-${get('day')}`;
|
||||
let hh = get('hour');
|
||||
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
|
||||
const time = `${hh}:${get('minute')}`;
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
|
||||
} catch {
|
||||
return { date: null, time: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
|
||||
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
||||
return {
|
||||
id: String(raw.id),
|
||||
fromCode: airportCode(raw.from),
|
||||
fromName: raw.from?.name ?? null,
|
||||
toCode: airportCode(raw.to),
|
||||
toName: raw.to?.name ?? null,
|
||||
date: raw.date ?? null,
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MappedEndpoint {
|
||||
role: 'from' | 'to' | 'stop';
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
}
|
||||
|
||||
export interface MappedReservation {
|
||||
title: string;
|
||||
type: 'flight';
|
||||
status: 'confirmed';
|
||||
reservation_time: string | null;
|
||||
reservation_end_time: string | null;
|
||||
notes: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
endpoints: MappedEndpoint[];
|
||||
needs_review: number;
|
||||
}
|
||||
|
||||
function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } {
|
||||
return !!a && typeof a.lat === 'number' && typeof a.lon === 'number';
|
||||
}
|
||||
|
||||
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||
const dep = localParts(raw.departure, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
|
||||
|
||||
const fromCode = airportCode(raw.from);
|
||||
const toCode = airportCode(raw.to);
|
||||
const datePrefix = raw.date || dep.date;
|
||||
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
|
||||
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
|
||||
|
||||
const endpoints: MappedEndpoint[] = [];
|
||||
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
|
||||
|
||||
if (hasCoords(raw.from)) {
|
||||
endpoints.push({
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: raw.from.name || fromCode || 'Departure',
|
||||
code: fromCode,
|
||||
lat: raw.from.lat,
|
||||
lng: raw.from.lon,
|
||||
timezone: raw.from.tz,
|
||||
local_time: dep.time,
|
||||
local_date: datePrefix,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
if (hasCoords(raw.to)) {
|
||||
endpoints.push({
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: raw.to.name || toCode || 'Arrival',
|
||||
code: toCode,
|
||||
lat: raw.to.lat,
|
||||
lng: raw.to.lon,
|
||||
timezone: raw.to.tz,
|
||||
local_time: arr.time,
|
||||
local_date: arr.date,
|
||||
});
|
||||
} else {
|
||||
needsReview = 1;
|
||||
}
|
||||
|
||||
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
|
||||
const airlineCode = entityCode(raw.airline);
|
||||
const aircraftCode = entityCode(raw.aircraft);
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (airlineCode) metadata.airline = airlineCode;
|
||||
if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
|
||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
||||
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
|
||||
|
||||
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
||||
// makes the clearest title; fall back to the route.
|
||||
const title = raw.flightNumber?.trim() || `${fromCode || '?'} → ${toCode || '?'}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
notes: raw.note ?? null,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: needsReview,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
|
||||
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
|
||||
* writes from re-triggering a pull. Only fields that can meaningfully change are
|
||||
* included, in a fixed key order.
|
||||
*/
|
||||
export function canonicalHash(raw: AirtrailFlightRaw): string {
|
||||
const snapshot = {
|
||||
from: airportCode(raw.from),
|
||||
to: airportCode(raw.to),
|
||||
date: raw.date ?? null,
|
||||
datePrecision: raw.datePrecision ?? 'day',
|
||||
departure: raw.departure ?? null,
|
||||
arrival: raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
aircraftReg: raw.aircraftReg ?? null,
|
||||
flightReason: raw.flightReason ?? null,
|
||||
note: raw.note ?? null,
|
||||
seats: (raw.seats ?? [])
|
||||
.map(s => ({
|
||||
userId: s.userId ?? null,
|
||||
guestName: s.guestName ?? null,
|
||||
seat: s.seat ?? null,
|
||||
seatNumber: s.seatNumber ?? null,
|
||||
seatClass: s.seatClass ?? null,
|
||||
}))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
};
|
||||
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { AirtrailFlight } from '@trek/shared';
|
||||
import { db } from '../../db/database';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { writeAudit } from '../auditLog';
|
||||
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
|
||||
import { normalizeFlight } from './airtrailMapper';
|
||||
|
||||
const KEY_MASK = '••••••••';
|
||||
|
||||
interface UserConnRow {
|
||||
airtrail_url?: string | null;
|
||||
airtrail_api_key?: string | null;
|
||||
airtrail_allow_insecure_tls?: number | null;
|
||||
}
|
||||
|
||||
function readRow(userId: number): UserConnRow | undefined {
|
||||
return db
|
||||
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
|
||||
.get(userId) as UserConnRow | undefined;
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = readRow(userId);
|
||||
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
|
||||
const apiKey = decrypt_api_key(row.airtrail_api_key);
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
baseUrl: row.airtrail_url,
|
||||
apiKey,
|
||||
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
|
||||
};
|
||||
}
|
||||
|
||||
/** Settings as shown in the UI — the key is never echoed, only masked. */
|
||||
export function getConnectionSettings(userId: number) {
|
||||
const row = readRow(userId);
|
||||
return {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
clientIp: string | null,
|
||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
let warning: string | undefined;
|
||||
|
||||
if (trimmedUrl) {
|
||||
const ssrf = await checkSsrf(trimmedUrl);
|
||||
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
|
||||
// loopback). Private/LAN instances are the common self-hosted case, so we
|
||||
// persist them with a warning rather than blocking — the outbound calls
|
||||
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
if (ssrf.isPrivate) {
|
||||
writeAudit({
|
||||
userId,
|
||||
action: 'airtrail.private_ip_configured',
|
||||
ip: clientIp,
|
||||
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
|
||||
});
|
||||
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only overwrite the stored key when a genuinely new value is supplied;
|
||||
// a blank field or the mask means "keep the existing key".
|
||||
const provided = (apiKey || '').trim();
|
||||
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
|
||||
|
||||
if (newKey !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||
if (!trimmedUrl) {
|
||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, warning };
|
||||
}
|
||||
|
||||
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
try {
|
||||
const flights = await listFlights(creds);
|
||||
return { connected: true, flightCount: flights.length };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
|
||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Live check using the stored connection. */
|
||||
export async function getConnectionStatus(
|
||||
userId: number,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) return { connected: false, error: 'Not configured' };
|
||||
return probe(creds);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test connection" from the settings form. Uses the typed URL/key when given;
|
||||
* falls back to the stored key when the key field still shows the mask.
|
||||
*/
|
||||
export async function testConnection(
|
||||
userId: number,
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
const provided = (apiKey || '').trim();
|
||||
|
||||
const stored = getAirtrailCredentials(userId);
|
||||
const effectiveUrl = trimmedUrl || stored?.baseUrl;
|
||||
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
return { connected: false, error: 'URL and API key required' };
|
||||
}
|
||||
|
||||
const ssrf = await checkSsrf(effectiveUrl);
|
||||
if (!ssrf.allowed && !ssrf.isPrivate) {
|
||||
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
|
||||
}
|
||||
|
||||
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
|
||||
}
|
||||
|
||||
/** The user's AirTrail flights, normalized for the import picker. */
|
||||
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
|
||||
const creds = getAirtrailCredentials(userId);
|
||||
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
|
||||
const raw = await listFlights(creds);
|
||||
return raw.map(normalizeFlight);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { db } from '../../db/database';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
AirtrailFlightRaw,
|
||||
AirtrailSavePayload,
|
||||
getFlight,
|
||||
listFlights,
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
export function syncGloballyEnabled(): boolean {
|
||||
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value !== 'false';
|
||||
}
|
||||
|
||||
function broadcastUpdated(tripId: number, reservationId: number): void {
|
||||
try {
|
||||
const reservation = getReservationWithJoins(reservationId);
|
||||
if (reservation) broadcast(tripId, 'reservation:updated', { reservation });
|
||||
} catch {
|
||||
/* broadcast failure is non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function detach(tripId: number, reservationId: number): void {
|
||||
db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId);
|
||||
broadcastUpdated(tripId, reservationId);
|
||||
}
|
||||
|
||||
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reconcile one owner's linked reservations against their current AirTrail
|
||||
* flights: apply field changes (detected by snapshot hash, since AirTrail has no
|
||||
* updated_at) and, when a flight is gone from AirTrail, keep the TREK row but
|
||||
* stop syncing it. Only already-imported flights are touched — new AirTrail
|
||||
* flights are never auto-added to a trip. Returns how many rows changed.
|
||||
*/
|
||||
async function syncOwner(uid: number): Promise<number> {
|
||||
const creds = getAirtrailCredentials(uid);
|
||||
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
|
||||
|
||||
let flights: AirtrailFlightRaw[];
|
||||
try {
|
||||
flights = await listFlights(creds);
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map(flights.map(f => [String(f.id), f]));
|
||||
|
||||
const linked = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
|
||||
)
|
||||
.all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[];
|
||||
|
||||
let changed = 0;
|
||||
for (const row of linked) {
|
||||
const flight = byId.get(String(row.external_id));
|
||||
if (!flight) {
|
||||
detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
|
||||
changed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = canonicalHash(flight);
|
||||
if (hash === row.external_hash) continue;
|
||||
|
||||
const current = getReservation(row.id, row.trip_id);
|
||||
if (!current) continue;
|
||||
try {
|
||||
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
hash,
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
broadcastUpdated(row.trip_id, row.id);
|
||||
changed++;
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
|
||||
/** Background poll across every connected owner (scheduler). */
|
||||
export async function runAirtrailSync(): Promise<void> {
|
||||
if (running) return;
|
||||
if (!syncGloballyEnabled()) return;
|
||||
running = true;
|
||||
let changed = 0;
|
||||
try {
|
||||
const owners = db
|
||||
.prepare(
|
||||
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
|
||||
)
|
||||
.all() as { uid: number }[];
|
||||
for (const { uid } of owners) changed += await syncOwner(uid);
|
||||
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand sync of just this user's linked flights — called when the user opens
|
||||
* a trip so AirTrail-side edits show up immediately instead of waiting for the
|
||||
* background poll.
|
||||
*/
|
||||
export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
|
||||
if (!syncGloballyEnabled()) return { changed: 0 };
|
||||
try {
|
||||
return { changed: await syncOwner(userId) };
|
||||
} catch (err) {
|
||||
logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`);
|
||||
return { changed: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── TREK → AirTrail (push) ───────────────────────────────────────────────────
|
||||
|
||||
function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } {
|
||||
if (!dt) return { date: null, time: null };
|
||||
const date = dt.slice(0, 10);
|
||||
const m = dt.slice(10).match(/(\d{2}:\d{2})/);
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
||||
}
|
||||
|
||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any> = {};
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find(e => e.role === 'from');
|
||||
const toEp = endpoints.find(e => e.role === 'to');
|
||||
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
|
||||
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
|
||||
if (!fromCode || !toCode) return null;
|
||||
|
||||
const dep = splitLocal(reservation.reservation_time);
|
||||
const arr = splitLocal(reservation.reservation_end_time);
|
||||
if (!dep.date) return null;
|
||||
|
||||
// Preserve the existing seat manifest (an update replaces all seats); fall back
|
||||
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
|
||||
const seats = (existing.seats ?? []).map(s => ({
|
||||
userId: s.userId,
|
||||
guestName: s.guestName,
|
||||
seat: s.seat,
|
||||
seatNumber: s.seatNumber,
|
||||
seatClass: s.seatClass,
|
||||
}));
|
||||
if (seats.length === 0) {
|
||||
seats.push({ userId: '<USER_ID>', guestName: null, seat: null, seatNumber: null, seatClass: null });
|
||||
}
|
||||
|
||||
// Push the seat the user set in TREK onto their own AirTrail seat (the one with
|
||||
// a userId), leaving any co-passenger seats untouched.
|
||||
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
|
||||
if (seatNumber) {
|
||||
const ownSeat = seats.find(s => s.userId) ?? seats[0];
|
||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(reservation.external_id),
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
departure: dep.date,
|
||||
departureTime: dep.time,
|
||||
arrival: arr.date,
|
||||
arrivalTime: arr.time,
|
||||
airline: meta.airline ?? null,
|
||||
flightNumber: meta.flight_number ?? null,
|
||||
aircraft: meta.aircraft ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? null,
|
||||
flightReason: meta.flight_reason ?? null,
|
||||
note: reservation.notes ?? null,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a locally-edited linked reservation back to AirTrail using the importer's
|
||||
* (owner's) credentials — even if a different member made the edit. If the owner
|
||||
* is gone or the flight no longer exists in AirTrail, the link is detached so the
|
||||
* next pull's AirTrail-wins policy can't silently revert the local edit.
|
||||
*/
|
||||
export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
|
||||
if (!syncGloballyEnabled()) return;
|
||||
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
|
||||
)
|
||||
.get(reservationId) as
|
||||
| { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number }
|
||||
| undefined;
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
||||
? getAirtrailCredentials(row.external_owner_user_id)
|
||||
: null;
|
||||
if (!creds) {
|
||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
}
|
||||
|
||||
let existing: AirtrailFlightRaw | null;
|
||||
try {
|
||||
existing = await getFlight(creds, Number(row.external_id));
|
||||
} catch (err) {
|
||||
if (err instanceof AirtrailAuthError) detach(tripId, row.id);
|
||||
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
if (!existing) {
|
||||
detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
|
||||
return;
|
||||
}
|
||||
|
||||
const reservation = getReservationWithJoins(row.id);
|
||||
if (!reservation) return;
|
||||
|
||||
const payload = buildSavePayload(reservation, existing);
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
await saveFlight(creds, payload);
|
||||
// Self-write suppression: re-read the saved flight and store its hash so the
|
||||
// next poll doesn't treat our own write as an inbound change.
|
||||
const saved = await getFlight(creds, Number(row.external_id));
|
||||
if (saved) {
|
||||
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
|
||||
canonicalHash(saved),
|
||||
new Date().toISOString(),
|
||||
row.id,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
@@ -251,6 +259,7 @@ function getNotifications(
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -250,38 +250,130 @@ interface OverpassPoiElement {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PoiSearchResult {
|
||||
pois: OverpassPoi[];
|
||||
source: 'openstreetmap';
|
||||
truncated: boolean;
|
||||
// True when the requested viewport was too large and got shrunk to a centred
|
||||
// window before querying — the results then cover the middle of the view only.
|
||||
clamped: boolean;
|
||||
}
|
||||
|
||||
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
|
||||
// Reachability and load vary a lot by network/region — the canonical instance is
|
||||
// frequently overloaded (504s) and some community mirrors are unreachable from
|
||||
// certain networks. Racing them means whichever mirror is fastest-reachable for
|
||||
// this user answers, and an overloaded or blocked one never blocks the others.
|
||||
const OVERPASS_MIRRORS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
'https://overpass.private.coffee/api/interpreter',
|
||||
];
|
||||
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
|
||||
// total wait before every mirror is given up on and a 502 is returned.
|
||||
const OVERPASS_TIMEOUT_MS = 12000;
|
||||
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
|
||||
// Overpass scan millions of elements and time out; clamping to a centred window
|
||||
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
|
||||
const MAX_BBOX_SPAN_DEG = 0.5;
|
||||
|
||||
// Short-lived cache so panning back over / re-toggling the same area doesn't
|
||||
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
|
||||
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
|
||||
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// Cap the number of cached areas so panning across the globe can't grow the map
|
||||
// without bound (entries are evicted oldest-first once the cap is reached).
|
||||
const POI_CACHE_MAX = 500;
|
||||
|
||||
// POST the query to all mirrors at once and return the first one that answers with
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
|
||||
// still surfaces a real error to the client instead of a silent "no places".
|
||||
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
|
||||
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
|
||||
return data.elements;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Promise.any resolves with the first mirror to return valid JSON, and only
|
||||
// rejects (AggregateError) once every mirror has failed.
|
||||
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
|
||||
} catch {
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
): Promise<PoiSearchResult> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Clamp an oversized viewport to a centred window so the query stays cheap and
|
||||
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
|
||||
let { south, west, north, east } = bbox;
|
||||
let clamped = false;
|
||||
if (north - south > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (north + south) / 2;
|
||||
south = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
north = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
if (east - west > MAX_BBOX_SPAN_DEG) {
|
||||
const c = (east + west) / 2;
|
||||
west = c - MAX_BBOX_SPAN_DEG / 2;
|
||||
east = c + MAX_BBOX_SPAN_DEG / 2;
|
||||
clamped = true;
|
||||
}
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
@@ -309,7 +401,11 @@ export async function searchOverpassPois(
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, limit), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
@@ -450,7 +546,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
|
||||
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -458,6 +554,18 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
|
||||
// Bias results toward the caller's area when supplied — without it Google Text
|
||||
// Search falls back to the API key's billing region, which skews foreign-region queries.
|
||||
if (locationBias) {
|
||||
searchBody.locationBias = {
|
||||
circle: {
|
||||
center: { latitude: locationBias.lat, longitude: locationBias.lng },
|
||||
radius: locationBias.radius ?? 50000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -465,7 +573,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
|
||||
},
|
||||
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
@@ -485,6 +593,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
rating: p.rating || null,
|
||||
website: p.websiteUri || null,
|
||||
phone: p.nationalPhoneNumber || null,
|
||||
types: p.types || [],
|
||||
source: 'google',
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { avatarUrl } from './authService';
|
||||
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'];
|
||||
|
||||
export { verifyTripAccess } from './tripAccess';
|
||||
|
||||
@@ -76,13 +76,14 @@ interface ImportItem {
|
||||
category?: string;
|
||||
weight_grams?: string | number;
|
||||
bag?: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
|
||||
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const created: any[] = [];
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
@@ -105,7 +106,8 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
|
||||
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
|
||||
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty);
|
||||
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db, getPlaceWithTags } from '../db/database';
|
||||
import { broadcast } from '../websocket';
|
||||
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
|
||||
|
||||
/**
|
||||
* Background enrichment for list-imported places (#886).
|
||||
*
|
||||
* Google/Naver list imports only carry name + coordinates, so the imported
|
||||
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
|
||||
* open/closed). When the importer opts in and a Google Maps key is configured,
|
||||
* we re-resolve each place by name — biased to and validated against the
|
||||
* imported coordinates — to a real Google place, then fill in the empty fields
|
||||
* and persist the resolved `google_place_id` (which is what powers on-demand
|
||||
* opening hours / the proper Maps link going forward).
|
||||
*
|
||||
* This runs detached from the import request (fire-and-forget) so a long list
|
||||
* never blocks the response, and pushes each enriched row over the websocket so
|
||||
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
|
||||
* can never clobber data the import already captured (e.g. a Naver address).
|
||||
*/
|
||||
|
||||
/** A place the import produced — only the fields enrichment reads/writes. */
|
||||
export interface EnrichablePlace {
|
||||
id: number;
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
google_place_id?: string | null;
|
||||
address?: string | null;
|
||||
website?: string | null;
|
||||
phone?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
/** How close a search hit must be to the imported coordinates to be trusted. */
|
||||
const MATCH_RADIUS_METERS = 250;
|
||||
/** Bias the text search to roughly the imported area. */
|
||||
const SEARCH_BIAS_RADIUS_METERS = 2000;
|
||||
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
|
||||
const ENRICH_CONCURRENCY = 3;
|
||||
|
||||
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLng = toRad(b.lng - a.lng);
|
||||
const lat1 = toRad(a.lat);
|
||||
const lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the search result that is the same place as the import: it must be a
|
||||
* Google result (have a google_place_id) with coordinates within
|
||||
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
|
||||
* null when nothing is close enough — in which case the place is left as
|
||||
* imported rather than risking a wrong-place overwrite (common-name / romanized
|
||||
* lists). Exported for unit testing.
|
||||
*/
|
||||
export function pickEnrichmentMatch(
|
||||
candidates: Record<string, unknown>[],
|
||||
target: { lat: number; lng: number },
|
||||
maxMeters: number = MATCH_RADIUS_METERS,
|
||||
): Record<string, unknown> | null {
|
||||
let best: { c: Record<string, unknown>; dist: number } | null = null;
|
||||
for (const c of candidates || []) {
|
||||
const gpid = c.google_place_id;
|
||||
const lat = c.lat;
|
||||
const lng = c.lng;
|
||||
if (typeof gpid !== 'string' || !gpid) continue;
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
|
||||
const dist = haversineMeters(target, { lat, lng });
|
||||
if (dist > maxMeters) continue;
|
||||
if (!best || dist < best.dist) best = { c, dist };
|
||||
}
|
||||
return best?.c ?? null;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++];
|
||||
await fn(item);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||
|
||||
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
|
||||
// Already linked (shouldn't happen for list imports) — nothing to resolve.
|
||||
if (place.google_place_id) return;
|
||||
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
|
||||
|
||||
const { places: results } = await searchPlaces(userId, place.name, lang, {
|
||||
lat: place.lat,
|
||||
lng: place.lng,
|
||||
radius: SEARCH_BIAS_RADIUS_METERS,
|
||||
});
|
||||
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
|
||||
if (!match) return;
|
||||
|
||||
const gpid = str(match.google_place_id);
|
||||
if (!gpid) return;
|
||||
|
||||
// COALESCE so enrichment only fills empty columns — never overwrites data the
|
||||
// import already captured (e.g. Naver's address) or anything the user edited.
|
||||
db.prepare(
|
||||
`UPDATE places
|
||||
SET google_place_id = COALESCE(google_place_id, ?),
|
||||
address = COALESCE(address, ?),
|
||||
website = COALESCE(website, ?),
|
||||
phone = COALESCE(phone, ?),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND trip_id = ?`,
|
||||
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
|
||||
|
||||
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
|
||||
// that case — a missing photo must never abort the rest of the enrichment.
|
||||
try {
|
||||
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
|
||||
if (photo?.photoUrl) {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
|
||||
).run(photo.photoUrl, place.id, tripId);
|
||||
}
|
||||
} catch {
|
||||
/* no photo — leave image_url as-is */
|
||||
}
|
||||
|
||||
// Push the enriched row to every connected client (no socket exclusion: the
|
||||
// importer's own client should also receive the late update).
|
||||
const updated = getPlaceWithTags(place.id);
|
||||
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a batch of just-imported places in the background. Never throws —
|
||||
* any per-place failure is swallowed so one bad lookup can't take down the
|
||||
* detached task or the process. No-ops when no Google Maps key is configured.
|
||||
*/
|
||||
export async function enrichImportedPlaces(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
places: EnrichablePlace[],
|
||||
lang?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!places.length) return;
|
||||
if (!getMapsKey(userId)) return;
|
||||
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
|
||||
try {
|
||||
await enrichOne(tripId, userId, place, lang);
|
||||
} catch (err) {
|
||||
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
resolveCategoryIdForFolder,
|
||||
type KmlImportSummary,
|
||||
} from './kmlImport';
|
||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||
|
||||
/** Opt-in Places-API enrichment for list imports (#886). */
|
||||
export interface ListImportOptions {
|
||||
enrich?: boolean;
|
||||
userId?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface PlaceWithCategory extends Place {
|
||||
category_name: string | null;
|
||||
@@ -595,7 +603,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
|
||||
// Import Google Maps list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function importGoogleList(tripId: string, url: string) {
|
||||
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
|
||||
let listId: string | null = null;
|
||||
let resolvedUrl = url;
|
||||
|
||||
@@ -697,6 +705,10 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
});
|
||||
insertAll();
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
@@ -707,6 +719,7 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
export async function importNaverList(
|
||||
tripId: string,
|
||||
url: string,
|
||||
opts?: ListImportOptions,
|
||||
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
|
||||
let resolvedUrl = url;
|
||||
const limit = 20;
|
||||
@@ -826,6 +839,10 @@ export async function importNaverList(
|
||||
});
|
||||
insertAll();
|
||||
|
||||
if (opts?.enrich && opts.userId && created.length) {
|
||||
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
|
||||
}
|
||||
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
|
||||
@@ -30,10 +30,14 @@ function mark(db: Database.Database, userId: number, code: string, name: string,
|
||||
).run(userId, code, name, country);
|
||||
}
|
||||
|
||||
// Rewind one migration and re-run so only the reconciliation (the last migration) executes.
|
||||
// The visited_regions reconciliation (#1119) is pinned at schema version 135.
|
||||
// Migrations added afterwards are appended AFTER it (append-only), so it is no
|
||||
// longer the last migration. Rewind to just before the reconciliation and
|
||||
// re-run: the later migrations are idempotent, so only the reconciliation has
|
||||
// any effect on the seeded rows here.
|
||||
const RECONCILIATION_VERSION = 135;
|
||||
function rerunLastMigration(db: Database.Database) {
|
||||
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(RECONCILIATION_VERSION - 1);
|
||||
runMigrations(db);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const res = await makeController({ search }).search(user, 'berlin', 'de');
|
||||
expect(res).toEqual({ places: [], source: 'osm' });
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de');
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
|
||||
});
|
||||
|
||||
it('maps a service error to its status + message', async () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper';
|
||||
import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
|
||||
|
||||
function airport(over: Partial<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
|
||||
return {
|
||||
id: 1,
|
||||
icao: 'KJFK',
|
||||
iata: 'JFK',
|
||||
name: 'John F. Kennedy Intl.',
|
||||
lat: 40.6413,
|
||||
lon: -73.7781,
|
||||
tz: 'America/New_York',
|
||||
country: 'US',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
|
||||
return {
|
||||
id: 42,
|
||||
from: airport(),
|
||||
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
|
||||
date: '2021-09-01',
|
||||
datePrecision: 'day',
|
||||
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
|
||||
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
|
||||
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
|
||||
flightNumber: 'BA178',
|
||||
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
|
||||
aircraftReg: 'G-VIIL',
|
||||
flightReason: 'leisure',
|
||||
note: 'window seat',
|
||||
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('airtrailMapper.normalizeFlight', () => {
|
||||
it('prefers IATA codes and exposes the picker fields', () => {
|
||||
const n = normalizeFlight(flight());
|
||||
expect(n).toMatchObject({
|
||||
id: '42',
|
||||
fromCode: 'JFK',
|
||||
toCode: 'LHR',
|
||||
date: '2021-09-01',
|
||||
airline: 'BAW',
|
||||
flightNumber: 'BA178',
|
||||
seatClass: 'economy',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
||||
const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null }));
|
||||
expect(n.fromCode).toBe('KJFK');
|
||||
expect(n.toCode).toBeNull();
|
||||
expect(n.toName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
it('composes airport-local times from the instant + airport tz', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||
// 07:00 UTC at LHR in September is 08:00 BST.
|
||||
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
|
||||
});
|
||||
|
||||
it('builds two endpoints with codes, coords and timezones', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
expect(m.endpoints).toHaveLength(2);
|
||||
expect(m.endpoints[0]).toMatchObject({ role: 'from', code: 'JFK', lat: 40.6413, timezone: 'America/New_York', local_date: '2021-09-01', local_time: '19:00' });
|
||||
expect(m.endpoints[1]).toMatchObject({ role: 'to', code: 'LHR', timezone: 'Europe/London', local_time: '08:00' });
|
||||
expect(m.needs_review).toBe(0);
|
||||
});
|
||||
|
||||
it('titles from the flight number, else the route', () => {
|
||||
expect(mapFlightToReservation(flight()).title).toBe('BA178');
|
||||
expect(mapFlightToReservation(flight({ airline: null, flightNumber: null })).title).toBe('JFK → LHR');
|
||||
});
|
||||
|
||||
it('carries flight metadata', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' });
|
||||
expect(m.type).toBe('flight');
|
||||
expect(m.status).toBe('confirmed');
|
||||
expect(m.notes).toBe('window seat');
|
||||
});
|
||||
|
||||
it('flags needs_review for a non-day date precision', () => {
|
||||
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
|
||||
});
|
||||
|
||||
it('flags needs_review and drops the endpoint when an airport has no coordinates', () => {
|
||||
const m = mapFlightToReservation(flight({ from: airport({ lat: null, lon: null }) }));
|
||||
expect(m.needs_review).toBe(1);
|
||||
expect(m.endpoints.find(e => e.role === 'from')).toBeUndefined();
|
||||
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves the end time null for a partial flight with no arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrival: null }));
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('airtrailMapper.canonicalHash', () => {
|
||||
it('is stable for the same flight', () => {
|
||||
expect(canonicalHash(flight())).toBe(canonicalHash(flight()));
|
||||
});
|
||||
|
||||
it('changes when a meaningful field changes', () => {
|
||||
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ flightNumber: 'BA179' })));
|
||||
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
|
||||
});
|
||||
|
||||
it('is independent of seat ordering', () => {
|
||||
const a = flight({
|
||||
seats: [
|
||||
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
|
||||
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
|
||||
],
|
||||
});
|
||||
const b = flight({
|
||||
seats: [
|
||||
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
|
||||
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
|
||||
],
|
||||
});
|
||||
expect(canonicalHash(a)).toBe(canonicalHash(b));
|
||||
});
|
||||
});
|
||||
@@ -275,3 +275,27 @@ describe('bulkImport with bag field', () => {
|
||||
expect(items[1].bag_id).toBe(bags[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── bulkImport with quantity field ────────────────────────────────────────────
|
||||
|
||||
describe('bulkImport with quantity field', () => {
|
||||
it('PACK-SVC-013: bulk import respects per-item quantity, defaults to 1, and clamps out-of-range', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
bulkImport(trip.id, [
|
||||
{ name: 'Socks', quantity: 5 },
|
||||
{ name: 'Toothbrush' },
|
||||
{ name: 'Batteries', quantity: 9999 },
|
||||
{ name: 'Charger', quantity: 0 },
|
||||
]);
|
||||
|
||||
const byName = (n: string) =>
|
||||
testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND name = ?').get(trip.id, n) as any;
|
||||
|
||||
expect(byName('Socks').quantity).toBe(5);
|
||||
expect(byName('Toothbrush').quantity).toBe(1);
|
||||
expect(byName('Batteries').quantity).toBe(999);
|
||||
expect(byName('Charger').quantity).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Unit tests for the import-enrichment match selector (#886).
|
||||
* Covers PENRICH-001 to PENRICH-004 — the coordinate-validation guard that
|
||||
* prevents a name search from overwriting an imported place with the wrong POI.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// placeEnrichment pulls in the DB, websocket and maps service at import time;
|
||||
// stub them so the pure match selector can be tested in isolation.
|
||||
vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
|
||||
vi.mock('../../../src/services/mapsService', () => ({
|
||||
getMapsKey: () => null,
|
||||
searchPlaces: async () => ({ places: [], source: 'none' }),
|
||||
getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
|
||||
}));
|
||||
|
||||
import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment';
|
||||
|
||||
const target = { lat: 48.85, lng: 2.35 };
|
||||
|
||||
describe('pickEnrichmentMatch', () => {
|
||||
it('PENRICH-001: picks the closest Google candidate within the radius', () => {
|
||||
const candidates = [
|
||||
{ google_place_id: 'far', lat: 48.8512, lng: 2.3512 }, // ~170 m
|
||||
{ google_place_id: 'near', lat: 48.85, lng: 2.35 }, // exact
|
||||
];
|
||||
const match = pickEnrichmentMatch(candidates, target);
|
||||
expect(match?.google_place_id).toBe('near');
|
||||
});
|
||||
|
||||
it('PENRICH-002: returns null when every candidate is beyond the radius', () => {
|
||||
const candidates = [{ google_place_id: 'A', lat: 48.86, lng: 2.36 }]; // ~1.2 km
|
||||
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
|
||||
});
|
||||
|
||||
it('PENRICH-003: ignores candidates without a google_place_id (e.g. OSM results)', () => {
|
||||
const candidates = [
|
||||
{ google_place_id: null, lat: 48.85, lng: 2.35 },
|
||||
{ name: 'no id', lat: 48.85, lng: 2.35 },
|
||||
];
|
||||
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
|
||||
});
|
||||
|
||||
it('PENRICH-004: ignores candidates with non-numeric coordinates', () => {
|
||||
const candidates = [{ google_place_id: 'A', lat: 'x', lng: 'y' }];
|
||||
expect(pickEnrichmentMatch(candidates as never, target)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* AirTrail integration contracts (#214).
|
||||
*
|
||||
* AirTrail is a self-hosted flight tracker (github.com/johanohly/AirTrail).
|
||||
* The connection is per-user (Settings → Integrations); the global on/off is the
|
||||
* `airtrail` addon. Each user stores their instance URL + a personal Bearer API
|
||||
* key, which only ever exposes that user's own flights.
|
||||
*/
|
||||
|
||||
// ── Per-user connection ──────────────────────────────────────────────────────
|
||||
|
||||
/** Placeholder the server returns instead of the real key once one is stored. */
|
||||
export const AIRTRAIL_KEY_MASK = '••••••••';
|
||||
|
||||
export const airtrailSettingsSchema = z.object({
|
||||
/** Instance origin, e.g. https://flights.example.com — TREK appends /api itself. */
|
||||
url: z.string().trim().max(2048),
|
||||
/** Bearer API key. Omitted / blank / the mask keeps the stored key unchanged. */
|
||||
apiKey: z.string().max(512).optional(),
|
||||
/** Allow self-signed TLS certs (common on LAN instances). */
|
||||
allowInsecureTls: z.boolean().optional().default(false),
|
||||
});
|
||||
export type AirtrailSettings = z.infer<typeof airtrailSettingsSchema>;
|
||||
|
||||
export const airtrailConnectionSchema = z.object({
|
||||
url: z.string(),
|
||||
apiKeyMasked: z.string(),
|
||||
allowInsecureTls: z.boolean(),
|
||||
connected: z.boolean(),
|
||||
});
|
||||
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
|
||||
|
||||
export const airtrailStatusSchema = z.object({
|
||||
connected: z.boolean(),
|
||||
flightCount: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type AirtrailStatus = z.infer<typeof airtrailStatusSchema>;
|
||||
|
||||
// ── Flight list (picker) ─────────────────────────────────────────────────────
|
||||
|
||||
/** A normalized AirTrail flight as surfaced to the import picker. */
|
||||
export const airtrailFlightSchema = z.object({
|
||||
id: z.string(),
|
||||
fromCode: z.string().nullable(),
|
||||
fromName: z.string().nullable(),
|
||||
toCode: z.string().nullable(),
|
||||
toName: z.string().nullable(),
|
||||
date: z.string().nullable(),
|
||||
departure: z.string().nullable(),
|
||||
arrival: z.string().nullable(),
|
||||
airline: z.string().nullable(),
|
||||
flightNumber: z.string().nullable(),
|
||||
aircraft: z.string().nullable(),
|
||||
seatClass: z.string().nullable(),
|
||||
});
|
||||
export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
|
||||
|
||||
// ── Import ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const airtrailImportSchema = z.object({
|
||||
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
|
||||
});
|
||||
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
|
||||
|
||||
/** Per-flight outcome of an import (so the picker can show what was skipped). */
|
||||
export const airtrailImportResultSchema = z.object({
|
||||
imported: z.array(z.string()),
|
||||
skipped: z.array(
|
||||
z.object({
|
||||
flightId: z.string(),
|
||||
reason: z.enum(['already-imported', 'already-in-trip', 'invalid']),
|
||||
detail: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type AirtrailImportResult = z.infer<typeof airtrailImportResultSchema>;
|
||||
@@ -17,7 +17,7 @@ describe('dayCreateRequestSchema', () => {
|
||||
});
|
||||
|
||||
describe('dayNoteCreateRequestSchema', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 150', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 250', () => {
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
|
||||
).toBe(true);
|
||||
@@ -30,7 +30,7 @@ describe('dayNoteCreateRequestSchema', () => {
|
||||
expect(
|
||||
dayNoteCreateRequestSchema.safeParse({
|
||||
text: 'ok',
|
||||
time: 'y'.repeat(151),
|
||||
time: 'y'.repeat(251),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
|
||||
|
||||
export const dayNoteCreateRequestSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional(),
|
||||
time: z.string().max(250).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
@@ -76,7 +76,7 @@ export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
|
||||
|
||||
export const dayNoteUpdateRequestSchema = z.object({
|
||||
text: z.string().max(500).optional(),
|
||||
time: z.string().max(150).optional(),
|
||||
time: z.string().max(250).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -86,5 +86,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
||||
'places.nameRequired': 'يرجى إدخال اسم',
|
||||
'places.saveError': 'فشل الحفظ',
|
||||
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
|
||||
'places.addAnyway': 'الإضافة على أي حال',
|
||||
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
|
||||
'places.enrichOnImportHint':
|
||||
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -140,5 +140,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.',
|
||||
'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.',
|
||||
'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.',
|
||||
'reservations.airtrail.title': 'استيراد من AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
|
||||
'reservations.airtrail.notSynced': 'غير متزامن',
|
||||
'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
|
||||
'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.',
|
||||
'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
|
||||
'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.',
|
||||
'reservations.airtrail.importError': 'فشل الاستيراد. يُرجى المحاولة مرة أخرى.',
|
||||
'reservations.airtrail.undo': 'استيراد من AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'مُستورَد',
|
||||
'reservations.airtrail.duringTrip': 'خلال هذه الرحلة',
|
||||
'reservations.airtrail.otherFlights': 'رحلات أخرى',
|
||||
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
|
||||
'reservations.airtrail.importCta': 'استيراد {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -319,6 +319,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'لم يُستخدم قط',
|
||||
'settings.mapPoiPill': 'استكشاف الأماكن على الخريطة',
|
||||
'settings.mapPoiPillHint': 'أظهر شريط فئات على خريطة الرحلة للعثور على المطاعم والفنادق والمزيد القريبة من OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'اربط نسخة AirTrail المُستضافة ذاتيًا لاستيراد الرحلات ومزامنتها. أنشئ مفتاح API في AirTrail ضمن الإعدادات ← الأمان.',
|
||||
'settings.airtrail.url': 'رابط النسخة',
|
||||
'settings.airtrail.apiKey': 'مفتاح API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'مفتاح API من نوع Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'يُنشأ في AirTrail ضمن الإعدادات ← الأمان. يُخزَّن مشفّرًا.',
|
||||
'settings.airtrail.allowInsecureTls': 'السماح بالشهادات الموقّعة ذاتيًا',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'فعّل هذا فقط لنسخة موثوقة على شبكتك الخاصة.',
|
||||
'settings.airtrail.connected': 'متصل',
|
||||
'settings.airtrail.notConnected': 'غير متصل',
|
||||
'settings.airtrail.toast.saved': 'تم حفظ اتصال AirTrail',
|
||||
'settings.airtrail.toast.saveError': 'تعذّر حفظ الاتصال',
|
||||
'settings.airtrail.test.button': 'اختبار الاتصال',
|
||||
'settings.airtrail.test.success': 'متصل — تم العثور على {count} رحلة/رحلات',
|
||||
'settings.airtrail.test.failed': 'فشل الاتصال',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Falha ao criar categoria',
|
||||
'places.nameRequired': 'Digite um nome',
|
||||
'places.saveError': 'Falha ao salvar',
|
||||
'places.duplicateExists': "'{name}' já está nesta viagem.",
|
||||
'places.addAnyway': 'Adicionar mesmo assim',
|
||||
'places.enrichOnImport': 'Enriquecer lugares via Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'A importação de reservas não está disponível neste servidor.',
|
||||
'reservations.import.unsupportedFormat': 'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.',
|
||||
'reservations.import.fileTooLarge': 'O arquivo "{name}" excede o limite de 10 MB.',
|
||||
'reservations.airtrail.title': 'Importar do AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.',
|
||||
'reservations.airtrail.notSynced': 'Não sincronizado',
|
||||
'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.',
|
||||
'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.',
|
||||
'reservations.airtrail.imported': '{count} voo(s) importado(s)',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} já nesta viagem, ignorado(s)',
|
||||
'reservations.airtrail.nothingImported': 'Nada para importar.',
|
||||
'reservations.airtrail.importError': 'Falha na importação. Tente novamente.',
|
||||
'reservations.airtrail.undo': 'Importar do AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Importado',
|
||||
'reservations.airtrail.duringTrip': 'Durante esta viagem',
|
||||
'reservations.airtrail.otherFlights': 'Outros voos',
|
||||
'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importar {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -325,6 +325,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Nunca usada',
|
||||
'settings.mapPoiPill': 'Explorar lugares no mapa',
|
||||
'settings.mapPoiPillHint': 'Mostrar uma etiqueta de categoria no mapa da viagem para encontrar restaurantes, hotéis e mais por perto a partir do OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Conecte seu AirTrail auto-hospedado para importar e sincronizar voos. Crie uma chave de API no AirTrail em Configurações → Segurança.',
|
||||
'settings.airtrail.url': 'URL da instância',
|
||||
'settings.airtrail.apiKey': 'Chave de API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Chave de API Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Gerada no AirTrail em Configurações → Segurança. Armazenada de forma criptografada.',
|
||||
'settings.airtrail.allowInsecureTls': 'Permitir certificados autoassinados',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Ative apenas para uma instância confiável na sua própria rede.',
|
||||
'settings.airtrail.connected': 'Conectado',
|
||||
'settings.airtrail.notConnected': 'Não conectado',
|
||||
'settings.airtrail.toast.saved': 'Conexão com o AirTrail salva',
|
||||
'settings.airtrail.toast.saveError': 'Não foi possível salvar a conexão',
|
||||
'settings.airtrail.test.button': 'Testar conexão',
|
||||
'settings.airtrail.test.success': 'Conectado — {count} voo(s) encontrado(s)',
|
||||
'settings.airtrail.test.failed': 'Falha na conexão',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -87,5 +87,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
||||
'places.nameRequired': 'Prosím zadejte název',
|
||||
'places.saveError': 'Uložení se nezdařilo',
|
||||
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
|
||||
'places.addAnyway': 'Přesto přidat',
|
||||
'places.enrichOnImport': 'Obohatit místa přes Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Vyhledá každé importované místo a doplní fotky, adresu a kontakty. Vyžaduje klíč Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -140,5 +140,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'Import rezervací není na tomto serveru k dispozici.',
|
||||
'reservations.import.unsupportedFormat': 'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.',
|
||||
'reservations.import.fileTooLarge': 'Soubor „{name}" překračuje limit 10 MB.',
|
||||
'reservations.airtrail.title': 'Import z AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Synchronizováno z AirTrail – úpravy zůstávají synchronní v obou směrech.',
|
||||
'reservations.airtrail.notSynced': 'Nesynchronizováno',
|
||||
'reservations.airtrail.notSyncedHint': 'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.',
|
||||
'reservations.airtrail.loadError': 'Vaše lety z AirTrail se nepodařilo načíst.',
|
||||
'reservations.airtrail.imported': 'Importováno letů: {count}',
|
||||
'reservations.airtrail.skippedDuplicate': 'Již v tomto výletu: {count}, přeskočeno',
|
||||
'reservations.airtrail.nothingImported': 'Není co importovat.',
|
||||
'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.',
|
||||
'reservations.airtrail.undo': 'Import z AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Importováno',
|
||||
'reservations.airtrail.duringTrip': 'Během tohoto výletu',
|
||||
'reservations.airtrail.otherFlights': 'Ostatní lety',
|
||||
'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
|
||||
'reservations.airtrail.importCta': 'Importovat {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -326,6 +326,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Nikdy nepoužito',
|
||||
'settings.mapPoiPill': 'Objevovat místa na mapě',
|
||||
'settings.mapPoiPillHint': 'Zobrazit na mapě výletu kategorie pro hledání restaurací, hotelů a dalšího v okolí z OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Připojte svou vlastní instanci AirTrail pro import a synchronizaci letů. Vytvořte API klíč v AirTrail v Nastavení → Zabezpečení.',
|
||||
'settings.airtrail.url': 'URL instance',
|
||||
'settings.airtrail.apiKey': 'API klíč',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'API klíč Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Vygenerován v AirTrail v Nastavení → Zabezpečení. Uložen šifrovaně.',
|
||||
'settings.airtrail.allowInsecureTls': 'Povolit certifikáty podepsané sebou samým',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Povolte pouze pro důvěryhodnou instanci ve vlastní síti.',
|
||||
'settings.airtrail.connected': 'Připojeno',
|
||||
'settings.airtrail.notConnected': 'Nepřipojeno',
|
||||
'settings.airtrail.toast.saved': 'Připojení k AirTrail uloženo',
|
||||
'settings.airtrail.toast.saveError': 'Připojení se nepodařilo uložit',
|
||||
'settings.airtrail.test.button': 'Otestovat připojení',
|
||||
'settings.airtrail.test.success': 'Připojeno – nalezeno letů: {count}',
|
||||
'settings.airtrail.test.failed': 'Připojení selhalo',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -112,7 +112,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.hero.badgeNext': 'ALS NÄCHSTES',
|
||||
'dashboard.hero.badgeRecent': 'KÜRZLICH',
|
||||
'dashboard.hero.tripDates': 'Reisedaten',
|
||||
'dashboard.hero.noDates': 'Keine Daten gesetzt',
|
||||
'dashboard.hero.noDates': 'Freie Planung',
|
||||
'dashboard.hero.travelerOne': '{count} Reisender',
|
||||
'dashboard.hero.travelerMany': '{count} Reisende',
|
||||
'dashboard.hero.destinationOne': '{count} Ziel',
|
||||
|
||||
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||
'places.nameRequired': 'Bitte einen Namen eingeben',
|
||||
'places.saveError': 'Fehler beim Speichern',
|
||||
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
|
||||
'places.addAnyway': 'Trotzdem hinzufügen',
|
||||
'places.enrichOnImport': 'Orte über Google anreichern',
|
||||
'places.enrichOnImportHint':
|
||||
'Sucht jeden importierten Ort nach, um Fotos, Adresse und Kontaktdaten zu ergänzen. Nutzt deinen Google-Maps-Key.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'Buchungsimport ist auf diesem Server nicht verfügbar.',
|
||||
'reservations.import.unsupportedFormat': 'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.',
|
||||
'reservations.import.fileTooLarge': 'Datei „{name}" überschreitet das 10-MB-Limit.',
|
||||
'reservations.airtrail.title': 'Aus AirTrail importieren',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Aus AirTrail synchronisiert — Änderungen bleiben in beide Richtungen synchron.',
|
||||
'reservations.airtrail.notSynced': 'Nicht synchronisiert',
|
||||
'reservations.airtrail.notSyncedHint': 'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.',
|
||||
'reservations.airtrail.loadError': 'Ihre AirTrail-Flüge konnten nicht geladen werden.',
|
||||
'reservations.airtrail.imported': '{count} Flug/Flüge importiert',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} bereits in dieser Reise, übersprungen',
|
||||
'reservations.airtrail.nothingImported': 'Nichts zu importieren.',
|
||||
'reservations.airtrail.importError': 'Import fehlgeschlagen. Bitte erneut versuchen.',
|
||||
'reservations.airtrail.undo': 'Aus AirTrail importieren',
|
||||
'reservations.airtrail.alreadyImported': 'Importiert',
|
||||
'reservations.airtrail.duringTrip': 'Während dieser Reise',
|
||||
'reservations.airtrail.otherFlights': 'Weitere Flüge',
|
||||
'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
|
||||
'reservations.airtrail.importCta': '{count} importieren',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -329,6 +329,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Noch nie verwendet',
|
||||
'settings.mapPoiPill': 'Orte auf der Karte entdecken',
|
||||
'settings.mapPoiPillHint': 'Zeigt auf der Reisekarte eine Kategorie-Pille an, um Restaurants, Hotels und mehr aus OpenStreetMap in der Nähe zu finden.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Verbinden Sie Ihr selbst gehostetes AirTrail, um Flüge zu importieren und zu synchronisieren. Erstellen Sie in AirTrail unter Einstellungen → Sicherheit einen API-Schlüssel.',
|
||||
'settings.airtrail.url': 'Instanz-URL',
|
||||
'settings.airtrail.apiKey': 'API-Schlüssel',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Bearer-API-Schlüssel',
|
||||
'settings.airtrail.apiKeyHint': 'Wird in AirTrail unter Einstellungen → Sicherheit erstellt. Verschlüsselt gespeichert.',
|
||||
'settings.airtrail.allowInsecureTls': 'Selbstsignierte Zertifikate erlauben',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Nur für eine vertrauenswürdige Instanz im eigenen Netzwerk aktivieren.',
|
||||
'settings.airtrail.connected': 'Verbunden',
|
||||
'settings.airtrail.notConnected': 'Nicht verbunden',
|
||||
'settings.airtrail.toast.saved': 'AirTrail-Verbindung gespeichert',
|
||||
'settings.airtrail.toast.saveError': 'Verbindung konnte nicht gespeichert werden',
|
||||
'settings.airtrail.test.button': 'Verbindung testen',
|
||||
'settings.airtrail.test.success': 'Verbunden — {count} Flug/Flüge gefunden',
|
||||
'settings.airtrail.test.failed': 'Verbindung fehlgeschlagen',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -125,7 +125,7 @@ const dashboard: TranslationStrings = {
|
||||
'dashboard.hero.badgeNext': 'UP NEXT',
|
||||
'dashboard.hero.badgeRecent': 'RECENT',
|
||||
'dashboard.hero.tripDates': 'Trip dates',
|
||||
'dashboard.hero.noDates': 'No dates set',
|
||||
'dashboard.hero.noDates': 'Open dates',
|
||||
'dashboard.hero.travelerOne': '{count} traveler',
|
||||
'dashboard.hero.travelerMany': '{count} travelers',
|
||||
'dashboard.hero.destinationOne': '{count} destination',
|
||||
|
||||
@@ -87,5 +87,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Failed to create category',
|
||||
'places.nameRequired': 'Please enter a name',
|
||||
'places.saveError': 'Failed to save',
|
||||
'places.duplicateExists': "'{name}' is already in this trip.",
|
||||
'places.addAnyway': 'Add anyway',
|
||||
'places.enrichOnImport': 'Enrich places via Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Look up each imported place to fill in photos, address and contact details. Uses your Google Maps key.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'Booking import is not available on this server.',
|
||||
'reservations.import.unsupportedFormat': 'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.',
|
||||
'reservations.import.fileTooLarge': 'File "{name}" exceeds 10 MB limit.',
|
||||
'reservations.airtrail.title': 'Import from AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Synced from AirTrail — edits stay in sync both ways.',
|
||||
'reservations.airtrail.notSynced': 'Not synced',
|
||||
'reservations.airtrail.notSyncedHint': 'This flight was removed in AirTrail and no longer syncs.',
|
||||
'reservations.airtrail.loadError': 'Could not load your AirTrail flights.',
|
||||
'reservations.airtrail.imported': '{count} flight(s) imported',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} already in this trip, skipped',
|
||||
'reservations.airtrail.nothingImported': 'Nothing to import.',
|
||||
'reservations.airtrail.importError': 'Import failed. Please try again.',
|
||||
'reservations.airtrail.undo': 'Import from AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Imported',
|
||||
'reservations.airtrail.duringTrip': 'During this trip',
|
||||
'reservations.airtrail.otherFlights': 'Other flights',
|
||||
'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
|
||||
'reservations.airtrail.importCta': 'Import {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -318,6 +318,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'This device',
|
||||
'settings.passkey.lastUsed': 'Last used',
|
||||
'settings.passkey.neverUsed': 'Never used',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Connect your self-hosted AirTrail to import and sync flights. Create an API key in AirTrail under Settings → Security.',
|
||||
'settings.airtrail.url': 'Instance URL',
|
||||
'settings.airtrail.apiKey': 'API key',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Bearer API key',
|
||||
'settings.airtrail.apiKeyHint': 'Generated in AirTrail under Settings → Security. Stored encrypted.',
|
||||
'settings.airtrail.allowInsecureTls': 'Allow self-signed certificates',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Enable only for a trusted instance on your own network.',
|
||||
'settings.airtrail.connected': 'Connected',
|
||||
'settings.airtrail.notConnected': 'Not connected',
|
||||
'settings.airtrail.toast.saved': 'AirTrail connection saved',
|
||||
'settings.airtrail.toast.saveError': 'Could not save the connection',
|
||||
'settings.airtrail.test.button': 'Test connection',
|
||||
'settings.airtrail.test.success': 'Connected — {count} flight(s) found',
|
||||
'settings.airtrail.test.failed': 'Connection failed',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'No se pudo crear la categoría',
|
||||
'places.nameRequired': 'Introduce un nombre',
|
||||
'places.saveError': 'No se pudo guardar',
|
||||
'places.duplicateExists': "'{name}' ya está en este viaje.",
|
||||
'places.addAnyway': 'Añadir de todos modos',
|
||||
'places.enrichOnImport': 'Enriquecer lugares con Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.',
|
||||
'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.',
|
||||
'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.',
|
||||
'reservations.airtrail.title': 'Importar desde AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.',
|
||||
'reservations.airtrail.notSynced': 'No sincronizado',
|
||||
'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.',
|
||||
'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.',
|
||||
'reservations.airtrail.imported': '{count} vuelo(s) importado(s)',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} ya en este viaje, omitido(s)',
|
||||
'reservations.airtrail.nothingImported': 'No hay nada que importar.',
|
||||
'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.',
|
||||
'reservations.airtrail.undo': 'Importar desde AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Importado',
|
||||
'reservations.airtrail.duringTrip': 'Durante este viaje',
|
||||
'reservations.airtrail.otherFlights': 'Otros vuelos',
|
||||
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importar {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -326,6 +326,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Nunca usada',
|
||||
'settings.mapPoiPill': 'Explorar lugares en el mapa',
|
||||
'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Conecta tu AirTrail autoalojado para importar y sincronizar vuelos. Crea una clave de API en AirTrail en Ajustes → Seguridad.',
|
||||
'settings.airtrail.url': 'URL de la instancia',
|
||||
'settings.airtrail.apiKey': 'Clave de API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Clave de API Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Generada en AirTrail en Ajustes → Seguridad. Se almacena cifrada.',
|
||||
'settings.airtrail.allowInsecureTls': 'Permitir certificados autofirmados',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Actívalo solo para una instancia de confianza en tu propia red.',
|
||||
'settings.airtrail.connected': 'Conectado',
|
||||
'settings.airtrail.notConnected': 'No conectado',
|
||||
'settings.airtrail.toast.saved': 'Conexión con AirTrail guardada',
|
||||
'settings.airtrail.toast.saveError': 'No se pudo guardar la conexión',
|
||||
'settings.airtrail.test.button': 'Probar conexión',
|
||||
'settings.airtrail.test.success': 'Conectado: {count} vuelo(s) encontrado(s)',
|
||||
'settings.airtrail.test.failed': 'Error de conexión',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -89,5 +89,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
||||
'places.nameRequired': 'Veuillez saisir un nom',
|
||||
'places.saveError': "Échec de l'enregistrement",
|
||||
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
|
||||
'places.addAnyway': 'Ajouter quand même',
|
||||
'places.enrichOnImport': 'Enrichir les lieux via Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Recherche chaque lieu importé pour ajouter photos, adresse et coordonnées. Utilise votre clé Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': "L'import de réservations n'est pas disponible sur ce serveur.",
|
||||
'reservations.import.unsupportedFormat': 'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.',
|
||||
'reservations.import.fileTooLarge': 'Le fichier « {name} » dépasse la limite de 10 Mo.',
|
||||
'reservations.airtrail.title': 'Importer depuis AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Synchronisé depuis AirTrail — les modifications restent synchronisées dans les deux sens.',
|
||||
'reservations.airtrail.notSynced': 'Non synchronisé',
|
||||
'reservations.airtrail.notSyncedHint': "Ce vol a été supprimé dans AirTrail et n'est plus synchronisé.",
|
||||
'reservations.airtrail.loadError': 'Impossible de charger vos vols AirTrail.',
|
||||
'reservations.airtrail.imported': '{count} vol(s) importé(s)',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} déjà dans ce voyage, ignoré(s)',
|
||||
'reservations.airtrail.nothingImported': 'Rien à importer.',
|
||||
'reservations.airtrail.importError': "Échec de l'importation. Veuillez réessayer.",
|
||||
'reservations.airtrail.undo': 'Importer depuis AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Importé',
|
||||
'reservations.airtrail.duringTrip': 'Pendant ce voyage',
|
||||
'reservations.airtrail.otherFlights': 'Autres vols',
|
||||
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Importer {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -331,6 +331,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Jamais utilisée',
|
||||
'settings.mapPoiPill': 'Explorer les lieux sur la carte',
|
||||
'settings.mapPoiPillHint': 'Afficher une pastille de catégorie sur la carte du voyage pour trouver à proximité des restaurants, hébergements et plus encore depuis OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Connectez votre instance AirTrail auto-hébergée pour importer et synchroniser vos vols. Créez une clé API dans AirTrail sous Paramètres → Sécurité.',
|
||||
'settings.airtrail.url': "URL de l'instance",
|
||||
'settings.airtrail.apiKey': 'Clé API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Clé API Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Générée dans AirTrail sous Paramètres → Sécurité. Stockée chiffrée.',
|
||||
'settings.airtrail.allowInsecureTls': 'Autoriser les certificats auto-signés',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'À activer uniquement pour une instance de confiance sur votre propre réseau.',
|
||||
'settings.airtrail.connected': 'Connecté',
|
||||
'settings.airtrail.notConnected': 'Non connecté',
|
||||
'settings.airtrail.toast.saved': 'Connexion AirTrail enregistrée',
|
||||
'settings.airtrail.toast.saveError': "Impossible d'enregistrer la connexion",
|
||||
'settings.airtrail.test.button': 'Tester la connexion',
|
||||
'settings.airtrail.test.success': 'Connecté — {count} vol(s) trouvé(s)',
|
||||
'settings.airtrail.test.failed': 'Échec de la connexion',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -90,5 +90,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας',
|
||||
'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα',
|
||||
'places.saveError': 'Αποτυχία αποθήκευσης',
|
||||
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
|
||||
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
|
||||
'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -143,5 +143,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.',
|
||||
'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.',
|
||||
'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.',
|
||||
'reservations.airtrail.title': 'Εισαγωγή από το AirTrail',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.',
|
||||
'reservations.airtrail.notSynced': 'Μη συγχρονισμένο',
|
||||
'reservations.airtrail.notSyncedHint': 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.',
|
||||
'reservations.airtrail.loadError': 'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.',
|
||||
'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν',
|
||||
'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.',
|
||||
'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.',
|
||||
'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail',
|
||||
'reservations.airtrail.alreadyImported': 'Εισήχθη',
|
||||
'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού',
|
||||
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
|
||||
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
|
||||
'reservations.airtrail.importCta': 'Εισαγωγή {count}',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -332,6 +332,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ',
|
||||
'settings.mapPoiPill': 'Εξερεύνηση μερών στον χάρτη',
|
||||
'settings.mapPoiPillHint': 'Εμφάνιση ετικέτας κατηγορίας στον χάρτη του ταξιδιού για εύρεση κοντινών εστιατορίων, ξενοδοχείων και άλλων από το OpenStreetMap.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Συνδέστε το αυτο-φιλοξενούμενο AirTrail σας για εισαγωγή και συγχρονισμό πτήσεων. Δημιουργήστε ένα κλειδί API στο AirTrail από Ρυθμίσεις → Ασφάλεια.',
|
||||
'settings.airtrail.url': 'URL της εγκατάστασης',
|
||||
'settings.airtrail.apiKey': 'Κλειδί API',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Κλειδί API τύπου Bearer',
|
||||
'settings.airtrail.apiKeyHint': 'Δημιουργείται στο AirTrail από Ρυθμίσεις → Ασφάλεια. Αποθηκεύεται κρυπτογραφημένο.',
|
||||
'settings.airtrail.allowInsecureTls': 'Να επιτρέπονται αυτο-υπογεγραμμένα πιστοποιητικά',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Ενεργοποιήστε το μόνο για μια αξιόπιστη εγκατάσταση στο δικό σας δίκτυο.',
|
||||
'settings.airtrail.connected': 'Συνδέθηκε',
|
||||
'settings.airtrail.notConnected': 'Δεν συνδέθηκε',
|
||||
'settings.airtrail.toast.saved': 'Η σύνδεση με το AirTrail αποθηκεύτηκε',
|
||||
'settings.airtrail.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση της σύνδεσης',
|
||||
'settings.airtrail.test.button': 'Δοκιμή σύνδεσης',
|
||||
'settings.airtrail.test.success': 'Συνδέθηκε — βρέθηκαν {count} πτήση/πτήσεις',
|
||||
'settings.airtrail.test.failed': 'Η σύνδεση απέτυχε',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -89,5 +89,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
||||
'places.nameRequired': 'Kérjük, adj meg egy nevet',
|
||||
'places.saveError': 'Nem sikerült menteni',
|
||||
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
|
||||
'places.addAnyway': 'Hozzáadás mindenképp',
|
||||
'places.enrichOnImport': 'Helyek gazdagítása a Google-lel',
|
||||
'places.enrichOnImportHint':
|
||||
'Minden importált helyet megkeres, hogy fotókat, címet és elérhetőséget adjon hozzá. Google Maps-kulcs szükséges.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.unavailable': 'A foglalásimportálás nem érhető el ezen a kiszolgálón.',
|
||||
'reservations.import.unsupportedFormat': 'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.',
|
||||
'reservations.import.fileTooLarge': 'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.',
|
||||
'reservations.airtrail.title': 'Importálás az AirTrailből',
|
||||
'reservations.airtrail.cta': 'AirTrail',
|
||||
'reservations.airtrail.synced': 'AirTrail',
|
||||
'reservations.airtrail.syncedHint': 'Az AirTrailből szinkronizálva — a módosítások mindkét irányban szinkronban maradnak.',
|
||||
'reservations.airtrail.notSynced': 'Nincs szinkronizálva',
|
||||
'reservations.airtrail.notSyncedHint': 'Ezt a járatot eltávolították az AirTrailből, és többé nem szinkronizálódik.',
|
||||
'reservations.airtrail.loadError': 'Nem sikerült betölteni az AirTrail-járataidat.',
|
||||
'reservations.airtrail.imported': '{count} járat importálva',
|
||||
'reservations.airtrail.skippedDuplicate': '{count} már szerepel ebben az utazásban, kihagyva',
|
||||
'reservations.airtrail.nothingImported': 'Nincs mit importálni.',
|
||||
'reservations.airtrail.importError': 'Az importálás sikertelen. Kérjük, próbáld újra.',
|
||||
'reservations.airtrail.undo': 'Importálás az AirTrailből',
|
||||
'reservations.airtrail.alreadyImported': 'Importálva',
|
||||
'reservations.airtrail.duringTrip': 'Az utazás ideje alatt',
|
||||
'reservations.airtrail.otherFlights': 'Egyéb járatok',
|
||||
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
|
||||
'reservations.airtrail.importCta': '{count} importálása',
|
||||
};
|
||||
export default reservations;
|
||||
|
||||
@@ -328,6 +328,21 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.neverUsed': 'Még nem használt',
|
||||
'settings.mapPoiPill': 'Helyek felfedezése a térképen',
|
||||
'settings.mapPoiPillHint': 'Megjelenít egy kategóriasávot az utazási térképen, hogy az OpenStreetMap segítségével közeli éttermeket, szállásokat és továbbiakat találj.',
|
||||
'settings.airtrail.title': 'AirTrail',
|
||||
'settings.airtrail.hint': 'Csatlakoztasd a saját üzemeltetésű AirTrail-példányodat járatok importálásához és szinkronizálásához. Hozz létre egy API-kulcsot az AirTrailben a Beállítások → Biztonság menüpontban.',
|
||||
'settings.airtrail.url': 'Példány URL-címe',
|
||||
'settings.airtrail.apiKey': 'API-kulcs',
|
||||
'settings.airtrail.apiKeyPlaceholder': 'Bearer API-kulcs',
|
||||
'settings.airtrail.apiKeyHint': 'Az AirTrailben a Beállítások → Biztonság menüpontban generálva. Titkosítva tárolva.',
|
||||
'settings.airtrail.allowInsecureTls': 'Önaláírt tanúsítványok engedélyezése',
|
||||
'settings.airtrail.allowInsecureTlsHint': 'Csak megbízható, saját hálózaton futó példány esetén engedélyezd.',
|
||||
'settings.airtrail.connected': 'Csatlakoztatva',
|
||||
'settings.airtrail.notConnected': 'Nincs csatlakoztatva',
|
||||
'settings.airtrail.toast.saved': 'AirTrail-kapcsolat mentve',
|
||||
'settings.airtrail.toast.saveError': 'Nem sikerült menteni a kapcsolatot',
|
||||
'settings.airtrail.test.button': 'Kapcsolat tesztelése',
|
||||
'settings.airtrail.test.success': 'Csatlakoztatva — {count} járat található',
|
||||
'settings.airtrail.test.failed': 'A kapcsolat sikertelen',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Gagal membuat kategori',
|
||||
'places.nameRequired': 'Harap masukkan nama',
|
||||
'places.saveError': 'Gagal menyimpan',
|
||||
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
|
||||
'places.addAnyway': 'Tetap tambahkan',
|
||||
'places.enrichOnImport': 'Perkaya tempat via Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Mencari setiap tempat yang diimpor untuk menambahkan foto, alamat, dan kontak. Memerlukan kunci Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user