From da5e77f78d872a997f7acf84b601b1b2149cb511 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 11:35:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20GPX=20file=20import=20for=20places?= =?UTF-8?q?=20=E2=80=94=20closes=20#98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload a GPX file to automatically create places from waypoints. Supports , , and elements with CDATA handling. Handles lat/lon in any attribute order. Track-only files import start and end points with the track name. - New server endpoint POST /places/import/gpx - Import GPX button in PlacesSidebar below Add Place - i18n keys for DE and EN --- client/src/api/client.ts | 4 + .../src/components/Planner/PlacesSidebar.tsx | 39 +++++++- client/src/i18n/translations/de.ts | 3 + client/src/i18n/translations/en.ts | 3 + client/src/pages/TripPlannerPage.tsx | 3 +- server/src/routes/places.ts | 91 +++++++++++++++++++ 6 files changed, 139 insertions(+), 4 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 346b7d8c..3301a266 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -91,6 +91,10 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: Record) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), + importGpx: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, } export const assignmentsApi = { diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 14f31293..3bef0791 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,15 +1,19 @@ import ReactDOM from 'react-dom' -import { useState } from 'react' +import { useState, useRef } from 'react' import DOM from 'react-dom' -import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react' +import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' +import { placesApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' interface PlacesSidebarProps { + tripId: number places: Place[] categories: Category[] assignments: AssignmentsMap @@ -26,11 +30,27 @@ interface PlacesSidebarProps { } export default function PlacesSidebar({ - places, categories, assignments, selectedDayId, selectedPlaceId, + tripId, places, categories, assignments, selectedDayId, selectedPlaceId, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, }: PlacesSidebarProps) { const { t } = useTranslation() + const toast = useToast() const ctxMenu = useContextMenu() + const gpxInputRef = useRef(null) + const tripStore = useTripStore() + + const handleGpxImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + try { + const result = await placesApi.importGpx(tripId, file) + await tripStore.loadTrip(tripId) + toast.success(t('places.gpxImported', { count: result.count })) + } catch (err: any) { + toast.error(err?.response?.data?.error || t('places.gpxError')) + } + } const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [categoryFilter, setCategoryFilterLocal] = useState('') @@ -72,6 +92,19 @@ export default function PlacesSidebar({ > {t('places.addPlace')} + + {/* Filter-Tabs */}
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index b09273e4..0aea7ff3 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -638,6 +638,9 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', + 'places.importGpx': 'GPX importieren', + 'places.gpxImported': '{count} Orte aus GPX importiert', + 'places.gpxError': 'GPX-Import fehlgeschlagen', 'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.all': 'Alle', 'places.unplanned': 'Ungeplant', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b85b6dc3..46e6310d 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -638,6 +638,9 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', + 'places.importGpx': 'Import GPX', + 'places.gpxImported': '{count} places imported from GPX', + 'places.gpxError': 'GPX import failed', 'places.assignToDay': 'Add to which day?', 'places.all': 'All', 'places.unplanned': 'Unplanned', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index b844c1dc..8992746f 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -495,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )}
{mobileSidebarOpen === 'left' ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> - : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> + : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> }
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 176aa3c7..f304e6a8 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -1,5 +1,6 @@ import express, { Request, Response } from 'express'; import fetch from 'node-fetch'; +import multer from 'multer'; import { db, getPlaceWithTags } from '../db/database'; import { authenticate } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; @@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers'; import { validateStringLengths } from '../middleware/validate'; import { AuthRequest, Place } from '../types'; +const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); + interface PlaceWithCategory extends Place { category_name: string | null; category_color: string | null; @@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); +// Import places from GPX file (must be before /:id) +router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => { + const { tripId } = req.params; + const file = (req as any).file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + const xml = file.buffer.toString('utf-8'); + + const parseCoords = (attrs: string): { lat: number; lng: number } | null => { + const latMatch = attrs.match(/lat=["']([^"']+)["']/i); + const lonMatch = attrs.match(/lon=["']([^"']+)["']/i); + if (!latMatch || !lonMatch) return null; + const lat = parseFloat(latMatch[1]); + const lng = parseFloat(lonMatch[1]); + return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null; + }; + + const stripCdata = (s: string) => s.replace(//g, '$1').trim(); + const extractName = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null }; + const extractDesc = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null }; + + const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = []; + + // 1) Parse elements (named waypoints / POIs) + const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi; + let match; + while ((match = wptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + + // 2) If no , try (route points) + if (waypoints.length === 0) { + const rteptRegex = /]+)>([\s\S]*?)<\/rtept>/gi; + while ((match = rteptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + } + + // 3) If still nothing, extract track name + start/end points from + if (waypoints.length === 0) { + const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); + const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; + const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; + const trackPoints: { lat: number; lng: number }[] = []; + while ((match = trkptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (coords) trackPoints.push(coords); + } + if (trackPoints.length > 0) { + const start = trackPoints[0]; + waypoints.push({ ...start, name: `${trackName} — Start`, description: null }); + if (trackPoints.length > 1) { + const end = trackPoints[trackPoints.length - 1]; + waypoints.push({ ...end, name: `${trackName} — End`, description: null }); + } + } + } + + if (waypoints.length === 0) { + return res.status(400).json({ error: 'No waypoints found in GPX file' }); + } + + const insertStmt = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, transport_mode) + VALUES (?, ?, ?, ?, ?, 'walking') + `); + const created: any[] = []; + const insertAll = db.transaction(() => { + for (const wp of waypoints) { + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng); + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + } + }); + insertAll(); + + res.status(201).json({ places: created, count: created.length }); + for (const place of created) { + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); + } +}); + router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { const { tripId, id } = req.params From 9a044ada28fb303b828658ea3d84dd2675f96aca Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 11:47:05 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20blur=20booking=20codes=20setting=20?= =?UTF-8?q?+=20two-column=20settings=20page=20=E2=80=94=20closes=20#114?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New display setting "Blur Booking Codes" (off by default) - When enabled, confirmation codes are blurred across all views (ReservationsPanel, DayDetailPanel, Transport detail modal) - Hover or click reveals the code (click toggles on mobile) - Settings page uses masonry two-column layout on desktop, single column on mobile (<900px) - Fix hardcoded admin page title to use i18n key --- .../src/components/Planner/DayDetailPanel.tsx | 8 +++- .../src/components/Planner/DayPlanSidebar.tsx | 27 +++++++++---- .../components/Planner/ReservationsPanel.tsx | 16 +++++++- client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/pages/AdminPage.tsx | 2 +- client/src/pages/SettingsPage.tsx | 40 +++++++++++++++++-- client/src/types.ts | 1 + 8 files changed, 83 insertions(+), 13 deletions(-) diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index d36cba90..3001b8a0 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -56,6 +56,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const { t, language, locale } = useTranslation() const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const fmtTime = (v) => formatTime12(v, is12h) const unit = isFahrenheit ? '°F' : '°C' const [weather, setWeather] = useState(null) @@ -368,7 +369,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
{linked.title}
{confirmed ? t('reservations.confirmed') : t('reservations.pending')} - {linked.confirmation_number && #{linked.confirmation_number}} + {linked.confirmation_number && { if (blurCodes) e.currentTarget.style.filter = 'none' }} + onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }} + onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }} + style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }} + >#{linked.confirmation_number}}
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 277662fd..1afcb48d 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -1449,7 +1449,7 @@ export default function DayPlanSidebar({ if (meta.platform) detailFields.push({ label: t('reservations.meta.platform'), value: meta.platform }) if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat }) } - if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number }) + if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true }) if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location }) return ( @@ -1486,12 +1486,25 @@ export default function DayPlanSidebar({ {/* Detail-Felder */} {detailFields.length > 0 && (
- {detailFields.map((f, i) => ( -
-
{f.label}
-
{f.value}
-
- ))} + {detailFields.map((f, i) => { + const shouldBlur = f.sensitive && useSettingsStore.getState().settings.blur_booking_codes + return ( +
+
{f.label}
+
{ if (shouldBlur) e.currentTarget.style.filter = 'none' }} + onMouseLeave={e => { if (shouldBlur) e.currentTarget.style.filter = 'blur(5px)' }} + onClick={e => { if (shouldBlur) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(5px)' : 'none' } }} + style={{ + fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word', + filter: shouldBlur ? 'blur(5px)' : 'none', transition: 'filter 0.2s', + cursor: shouldBlur ? 'pointer' : 'default', + userSelect: shouldBlur ? 'none' : 'auto', + }} + >{f.value}
+
+ ) + })}
)} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index cd36f061..987ff184 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -63,6 +63,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo const toast = useToast() const { t, locale } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) + const [codeRevealed, setCodeRevealed] = useState(false) const typeInfo = getType(r.type) const TypeIcon = typeInfo.Icon const confirmed = r.status === 'confirmed' @@ -136,7 +138,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo {r.confirmation_number && (
{t('reservations.confirmationCode')}
-
{r.confirmation_number}
+
blurCodes && setCodeRevealed(true)} + onMouseLeave={() => blurCodes && setCodeRevealed(false)} + onClick={() => blurCodes && setCodeRevealed(v => !v)} + style={{ + fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1, + filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none', + cursor: blurCodes ? 'pointer' : 'default', + transition: 'filter 0.2s', + }} + > + {r.confirmation_number} +
)} diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 0aea7ff3..9449693f 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -139,6 +139,7 @@ const de: Record = { 'settings.temperature': 'Temperatureinheit', 'settings.timeFormat': 'Zeitformat', 'settings.routeCalculation': 'Routenberechnung', + 'settings.blurBookingCodes': 'Buchungscodes verbergen', 'settings.on': 'An', 'settings.off': 'Aus', 'settings.account': 'Konto', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 46e6310d..95e2018d 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -139,6 +139,7 @@ const en: Record = { 'settings.temperature': 'Temperature Unit', 'settings.timeFormat': 'Time Format', 'settings.routeCalculation': 'Route Calculation', + 'settings.blurBookingCodes': 'Blur Booking Codes', 'settings.on': 'On', 'settings.off': 'Off', 'settings.account': 'Account', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b63306ff..b729a612 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -333,7 +333,7 @@ export default function AdminPage(): React.ReactElement {
-

Administration

+

{t('admin.title')}

{t('admin.subtitle')}

diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 7846f5ee..49b5c8a8 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -34,7 +34,7 @@ interface SectionProps { function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement { return ( -
+

{title}

@@ -220,12 +220,15 @@ export default function SettingsPage(): React.ReactElement {
-
-
+
+ +

{t('settings.title')}

{t('settings.subtitle')}

+
+ {/* Map settings */}
@@ -439,6 +442,36 @@ export default function SettingsPage(): React.ReactElement { ))}
+ + {/* Blur Booking Codes */} +
+ +
+ {[ + { value: true, label: t('settings.on') || 'On' }, + { value: false, label: t('settings.off') || 'Off' }, + ].map(opt => ( + + ))} +
+
{/* Immich — only when Memories addon is enabled */} @@ -888,6 +921,7 @@ export default function SettingsPage(): React.ReactElement {
)} +
diff --git a/client/src/types.ts b/client/src/types.ts index e5913abe..f0da1ee2 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -171,6 +171,7 @@ export interface Settings { time_format: string show_place_description: boolean route_calculation?: boolean + blur_booking_codes?: boolean } export interface AssignmentsMap { From e6c4c22a1d9beab4f288ac60070a283dc70bb361 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 12:16:00 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20bulk=20import=20for=20packing=20lis?= =?UTF-8?q?ts=20+=20complete=20i18n=20sync=20=E2=80=94=20closes=20#133?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packing list bulk import: - Import button in packing list header opens a modal - Paste items or load CSV/TXT file - Format: Category, Name, Weight (g), Bag, checked/unchecked - Bags are auto-created if they don't exist - Server endpoint POST /packing/import with transaction i18n sync: - Added all missing translation keys to fr, es, nl, ru, zh, ar - All 8 language files now have matching key sets - Includes memories, vacay weekdays, packing import, settlement, GPX import, blur booking codes, transport timeline keys --- client/src/api/client.ts | 1 + .../components/Packing/PackingListPanel.tsx | 105 +++++++++++++++++- client/src/i18n/translations/ar.ts | 45 ++++++++ client/src/i18n/translations/de.ts | 9 ++ client/src/i18n/translations/en.ts | 9 ++ client/src/i18n/translations/es.ts | 45 ++++++++ client/src/i18n/translations/fr.ts | 45 ++++++++ client/src/i18n/translations/nl.ts | 45 ++++++++ client/src/i18n/translations/ru.ts | 45 ++++++++ client/src/i18n/translations/zh.ts | 45 ++++++++ server/src/routes/packing.ts | 47 ++++++++ 11 files changed, 440 insertions(+), 1 deletion(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 3301a266..8f095159 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -112,6 +112,7 @@ export const assignmentsApi = { export const packingApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data), create: (tripId: number | string, data: Record) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data), + bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).then(r => r.data), update: (tripId: number | string, id: number, data: Record) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data), diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index 7d670dbb..d1cc0a05 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -3,9 +3,10 @@ import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { packingApi, tripsApi, adminApi } from '../../api/client' +import ReactDOM from 'react-dom' import { CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight, - X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, + X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage, UserPlus, Package, FolderPlus, Upload, } from 'lucide-react' import type { PackingItem } from '../../types' @@ -727,6 +728,9 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp const [availableTemplates, setAvailableTemplates] = useState<{ id: number; name: string; item_count: number }[]>([]) const [showTemplateDropdown, setShowTemplateDropdown] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) + const [importText, setImportText] = useState('') + const csvInputRef = useRef(null) const templateDropdownRef = useRef(null) useEffect(() => { @@ -757,6 +761,44 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp } } + const parseImportLines = (text: string) => { + return text.split('\n').map(line => line.trim()).filter(Boolean).map(line => { + // Format: Category, Name, Weight (optional), Bag (optional), checked/unchecked (optional) + const parts = line.split(/[,;\t]/).map(s => s.trim()) + if (parts.length >= 2) { + const category = parts[0] + const name = parts[1] + const weight_grams = parts[2] || undefined + const bag = parts[3] || undefined + const checked = parts[4]?.toLowerCase() === 'checked' || parts[4] === '1' + return { name, category, weight_grams, bag, checked } + } + // Single value = just a name + return { name: parts[0], category: undefined, weight_grams: undefined, bag: undefined, checked: false } + }).filter(i => i.name) + } + + const handleBulkImport = async () => { + const parsed = parseImportLines(importText) + if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } + try { + const result = await packingApi.bulkImport(tripId, parsed) + toast.success(t('packing.importSuccess', { count: result.count })) + setImportText('') + setShowImportModal(false) + window.location.reload() + } catch { toast.error(t('packing.importError')) } + } + + const handleCsvFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + const reader = new FileReader() + reader.onload = () => { if (typeof reader.result === 'string') setImportText(reader.result) } + reader.readAsText(file) + } + const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } return ( @@ -781,6 +823,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp {t('packing.clearCheckedShort', { count: abgehakt })} )} + {availableTemplates.length > 0 && (