diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index ed4f5b98..f4404699 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -107,6 +107,8 @@ export const placesApi = {
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)
},
+ importGoogleList: (tripId: number | string, url: string) =>
+ apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
}
export const assignmentsApi = {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 6081b728..38c61ffd 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -12,6 +12,8 @@ import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -1331,7 +1333,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{note.text}
{note.time && (
-
{note.time}
+ {note.time}
)}
{canEditDays &&
@@ -1610,7 +1612,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.notes && (
{t('reservations.notes')}
-
{res.notes}
+
{res.notes}
)}
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index 1fe4ace2..f6c00a57 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -1,5 +1,7 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { getAuthUrl } from '../../api/authUrl'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
@@ -340,10 +342,8 @@ export default function PlaceInspector({
{/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && (
-
-
- {place.description || place.notes || googleDetails?.summary}
-
+
+ {place.description || place.notes || googleDetails?.summary || ''}
)}
@@ -392,7 +392,7 @@ export default function PlaceInspector({
)}
- {res.notes && {res.notes}
}
+ {res.notes && {res.notes}
}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 2b2b3682..17cbbe82 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useMemo, useCallback } from 'react'
import DOM from 'react-dom'
-import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
+import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
@@ -56,6 +56,27 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
toast.error(err?.response?.data?.error || t('places.gpxError'))
}
}
+
+ const [googleListOpen, setGoogleListOpen] = useState(false)
+ const [googleListUrl, setGoogleListUrl] = useState('')
+ const [googleListLoading, setGoogleListLoading] = useState(false)
+
+ const handleGoogleListImport = async () => {
+ if (!googleListUrl.trim()) return
+ setGoogleListLoading(true)
+ try {
+ const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
+ await loadTrip(tripId)
+ toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
+ setGoogleListOpen(false)
+ setGoogleListUrl('')
+ } catch (err: any) {
+ toast.error(err?.response?.data?.error || t('places.googleListError'))
+ } finally {
+ setGoogleListLoading(false)
+ }
+ }
+
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set())
@@ -105,18 +126,32 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
}
{canEditPlaces && <>
-
+
+
+
+
>}
{/* Filter-Tabs */}
@@ -366,6 +401,64 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
,
document.body
)}
+ {googleListOpen && ReactDOM.createPortal(
+ { setGoogleListOpen(false); setGoogleListUrl('') }}
+ style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
+ >
+
e.stopPropagation()}
+ style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
+ >
+
+ {t('places.importGoogleList')}
+
+
+ {t('places.googleListHint')}
+
+
setGoogleListUrl(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
+ placeholder="https://maps.app.goo.gl/..."
+ autoFocus
+ style={{
+ width: '100%', padding: '10px 14px', borderRadius: 10,
+ border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)',
+ fontSize: 13, color: 'var(--text-primary)', outline: 'none',
+ fontFamily: 'inherit', boxSizing: 'border-box',
+ }}
+ />
+
+
+
+
+
+
,
+ document.body
+ )}
)
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index f38a2c7e..8850c051 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -10,6 +10,7 @@ const ar: Record = {
'common.edit': 'تعديل',
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
+ 'common.import': 'استيراد',
'common.error': 'خطأ',
'common.back': 'رجوع',
'common.all': 'الكل',
@@ -783,9 +784,13 @@ const ar: Record = {
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
- 'places.importGpx': 'استيراد GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
+ 'places.importGoogleList': 'قائمة Google',
+ 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
+ 'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
+ 'places.googleListError': 'فشل استيراد قائمة Google Maps',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 0f0cef13..74949939 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -6,6 +6,7 @@ const br: Record = {
'common.edit': 'Editar',
'common.add': 'Adicionar',
'common.loading': 'Carregando...',
+ 'common.import': 'Importar',
'common.error': 'Erro',
'common.back': 'Voltar',
'common.all': 'Todos',
@@ -763,9 +764,13 @@ const br: Record = {
// Places Sidebar
'places.addPlace': 'Adicionar lugar/atividade',
- 'places.importGpx': 'Importar GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxError': 'Falha ao importar GPX',
+ 'places.importGoogleList': 'Lista Google',
+ 'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
+ 'places.googleListImported': '{count} lugares importados de "{list}"',
+ 'places.googleListError': 'Falha ao importar lista do Google Maps',
'places.urlResolved': 'Lugar importado da URL',
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index b5c96aed..6c88b6dc 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -6,6 +6,7 @@ const cs: Record = {
'common.edit': 'Upravit',
'common.add': 'Přidat',
'common.loading': 'Načítání...',
+ 'common.import': 'Importovat',
'common.error': 'Chyba',
'common.back': 'Zpět',
'common.all': 'Vše',
@@ -783,10 +784,14 @@ const cs: Record = {
// Boční panel míst (Places Sidebar)
'places.addPlace': 'Přidat místo/aktivitu',
- 'places.importGpx': 'Importovat GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} míst importováno z GPX',
'places.urlResolved': 'Místo importováno z URL',
'places.gpxError': 'Import GPX se nezdařil',
+ 'places.importGoogleList': 'Google Seznam',
+ 'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
+ 'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
+ 'places.googleListError': 'Import seznamu Google Maps se nezdařil',
'places.assignToDay': 'Přidat do kterého dne?',
'places.all': 'Vše',
'places.unplanned': 'Nezařazené',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index e83a4a3a..4706f504 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -6,6 +6,7 @@ const de: Record = {
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
'common.loading': 'Laden...',
+ 'common.import': 'Importieren',
'common.error': 'Fehler',
'common.back': 'Zurück',
'common.all': 'Alle',
@@ -781,10 +782,14 @@ const de: Record = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
- 'places.importGpx': 'GPX importieren',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
+ 'places.importGoogleList': 'Google Liste',
+ 'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
+ 'places.googleListImported': '{count} Orte aus "{list}" importiert',
+ 'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
'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 816b4ad1..e2d9f9e4 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -6,6 +6,7 @@ const en: Record = {
'common.edit': 'Edit',
'common.add': 'Add',
'common.loading': 'Loading...',
+ 'common.import': 'Import',
'common.error': 'Error',
'common.back': 'Back',
'common.all': 'All',
@@ -777,10 +778,14 @@ const en: Record = {
// Places Sidebar
'places.addPlace': 'Add Place/Activity',
- 'places.importGpx': 'Import GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} places imported from GPX',
'places.urlResolved': 'Place imported from URL',
'places.gpxError': 'GPX import failed',
+ 'places.importGoogleList': 'Google List',
+ 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
+ 'places.googleListImported': '{count} places imported from "{list}"',
+ 'places.googleListError': 'Failed to import Google Maps list',
'places.assignToDay': 'Add to which day?',
'places.all': 'All',
'places.unplanned': 'Unplanned',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index 5a1f180e..d68a70c1 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -6,6 +6,7 @@ const es: Record = {
'common.edit': 'Editar',
'common.add': 'Añadir',
'common.loading': 'Cargando...',
+ 'common.import': 'Importar',
'common.error': 'Error',
'common.back': 'Atrás',
'common.all': 'Todo',
@@ -757,9 +758,13 @@ const es: Record = {
// Places Sidebar
'places.addPlace': 'Añadir lugar/actividad',
- 'places.importGpx': 'Importar GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxError': 'Error al importar GPX',
+ 'places.importGoogleList': 'Lista Google',
+ 'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
+ 'places.googleListImported': '{count} lugares importados de "{list}"',
+ 'places.googleListError': 'Error al importar la lista de Google Maps',
'places.urlResolved': 'Lugar importado desde URL',
'places.assignToDay': '¿A qué día añadirlo?',
'places.all': 'Todo',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index 55b334bc..5b908794 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -6,6 +6,7 @@ const fr: Record = {
'common.edit': 'Modifier',
'common.add': 'Ajouter',
'common.loading': 'Chargement…',
+ 'common.import': 'Importer',
'common.error': 'Erreur',
'common.back': 'Retour',
'common.all': 'Tout',
@@ -780,9 +781,13 @@ const fr: Record = {
// Places Sidebar
'places.addPlace': 'Ajouter un lieu/activité',
- 'places.importGpx': 'Importer GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxError': 'L\'import GPX a échoué',
+ 'places.importGoogleList': 'Liste Google',
+ 'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
+ 'places.googleListImported': '{count} lieux importés depuis "{list}"',
+ 'places.googleListError': 'Impossible d\'importer la liste Google Maps',
'places.urlResolved': 'Lieu importé depuis l\'URL',
'places.assignToDay': 'Ajouter à quel jour ?',
'places.all': 'Tous',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index 67928e89..975db93f 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -6,6 +6,7 @@ const hu: Record = {
'common.edit': 'Szerkesztés',
'common.add': 'Hozzáadás',
'common.loading': 'Betöltés...',
+ 'common.import': 'Importálás',
'common.error': 'Hiba',
'common.back': 'Vissza',
'common.all': 'Összes',
@@ -779,10 +780,14 @@ const hu: Record = {
// Helyek oldalsáv
'places.addPlace': 'Hely/Tevékenység hozzáadása',
- 'places.importGpx': 'GPX importálás',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} hely importálva GPX-ből',
'places.urlResolved': 'Hely importálva URL-ből',
'places.gpxError': 'GPX importálás sikertelen',
+ 'places.importGoogleList': 'Google Lista',
+ 'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
+ 'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
+ 'places.googleListError': 'Google Maps lista importalasa sikertelen',
'places.assignToDay': 'Melyik naphoz adod?',
'places.all': 'Összes',
'places.unplanned': 'Nem tervezett',
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 85ed1b2d..94ef8997 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -6,6 +6,7 @@ const it: Record = {
'common.edit': 'Modifica',
'common.add': 'Aggiungi',
'common.loading': 'Caricamento...',
+ 'common.import': 'Importa',
'common.error': 'Errore',
'common.back': 'Indietro',
'common.all': 'Tutti',
@@ -779,10 +780,14 @@ const it: Record = {
// Places Sidebar
'places.addPlace': 'Aggiungi Luogo/Attività',
- 'places.importGpx': 'Importa GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} luoghi importati da GPX',
'places.urlResolved': 'Luogo importato dall\'URL',
'places.gpxError': 'Importazione GPX non riuscita',
+ 'places.importGoogleList': 'Lista Google',
+ 'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
+ 'places.googleListImported': '{count} luoghi importati da "{list}"',
+ 'places.googleListError': 'Importazione lista Google Maps non riuscita',
'places.assignToDay': 'A quale giorno aggiungere?',
'places.all': 'Tutti',
'places.unplanned': 'Non pianificati',
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index e448a878..cd881633 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -6,6 +6,7 @@ const nl: Record = {
'common.edit': 'Bewerken',
'common.add': 'Toevoegen',
'common.loading': 'Laden...',
+ 'common.import': 'Importeren',
'common.error': 'Fout',
'common.back': 'Terug',
'common.all': 'Alles',
@@ -780,9 +781,13 @@ const nl: Record = {
// Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen',
- 'places.importGpx': 'GPX importeren',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxError': 'GPX-import mislukt',
+ 'places.importGoogleList': 'Google Lijst',
+ 'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
+ 'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
+ 'places.googleListError': 'Google Maps lijst importeren mislukt',
'places.urlResolved': 'Plaats geïmporteerd van URL',
'places.assignToDay': 'Aan welke dag toevoegen?',
'places.all': 'Alle',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 0710cdb5..3e45fb4b 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -6,6 +6,7 @@ const ru: Record = {
'common.edit': 'Редактировать',
'common.add': 'Добавить',
'common.loading': 'Загрузка...',
+ 'common.import': 'Импорт',
'common.error': 'Ошибка',
'common.back': 'Назад',
'common.all': 'Все',
@@ -780,9 +781,13 @@ const ru: Record = {
// Places Sidebar
'places.addPlace': 'Добавить место/активность',
- 'places.importGpx': 'Импорт GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxError': 'Ошибка импорта GPX',
+ 'places.importGoogleList': 'Список Google',
+ 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
+ 'places.googleListImported': '{count} мест импортировано из "{list}"',
+ 'places.googleListError': 'Не удалось импортировать список Google Maps',
'places.urlResolved': 'Место импортировано из URL',
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index 47bab93e..38d737c9 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -6,6 +6,7 @@ const zh: Record = {
'common.edit': '编辑',
'common.add': '添加',
'common.loading': '加载中...',
+ 'common.import': '导入',
'common.error': '错误',
'common.back': '返回',
'common.all': '全部',
@@ -780,9 +781,13 @@ const zh: Record = {
// Places Sidebar
'places.addPlace': '添加地点/活动',
- 'places.importGpx': '导入 GPX',
+ 'places.importGpx': 'GPX',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
+ 'places.importGoogleList': 'Google 列表',
+ 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
+ 'places.googleListImported': '已从"{list}"导入 {count} 个地点',
+ 'places.googleListError': 'Google Maps 列表导入失败',
'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 2f3b2143..5dd4b10e 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -214,6 +214,111 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi
}
});
+// Import places from a shared Google Maps list URL
+router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
+ const authReq = req as AuthRequest;
+ if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
+ return res.status(403).json({ error: 'No permission' });
+
+ const { tripId } = req.params;
+ const { url } = req.body;
+ if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
+
+ try {
+ // Extract list ID from various Google Maps list URL formats
+ let listId: string | null = null;
+ let resolvedUrl = url;
+
+ // Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
+ if (url.includes('goo.gl') || url.includes('maps.app')) {
+ const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
+ resolvedUrl = redirectRes.url;
+ }
+
+ // Pattern: /placelists/list/{ID}
+ const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/);
+ if (plMatch) listId = plMatch[1];
+
+ // Pattern: !2s{ID} in data URL params
+ if (!listId) {
+ const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/);
+ if (dataMatch) listId = dataMatch[1];
+ }
+
+ if (!listId) {
+ return res.status(400).json({ error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.' });
+ }
+
+ // Fetch list data from Google Maps internal API
+ const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`;
+ const apiRes = await fetch(apiUrl, {
+ headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
+ signal: AbortSignal.timeout(15000),
+ });
+
+ if (!apiRes.ok) {
+ return res.status(502).json({ error: 'Failed to fetch list from Google Maps' });
+ }
+
+ const rawText = await apiRes.text();
+ const jsonStr = rawText.substring(rawText.indexOf('\n') + 1);
+ const listData = JSON.parse(jsonStr);
+
+ const meta = listData[0];
+ if (!meta) {
+ return res.status(400).json({ error: 'Invalid list data received from Google Maps' });
+ }
+
+ const listName = meta[4] || 'Google Maps List';
+ const items = meta[8];
+
+ if (!Array.isArray(items) || items.length === 0) {
+ return res.status(400).json({ error: 'List is empty or could not be read' });
+ }
+
+ // Parse place data from items
+ const places: { name: string; lat: number; lng: number; notes: string | null }[] = [];
+ for (const item of items) {
+ const coords = item?.[1]?.[5];
+ const lat = coords?.[2];
+ const lng = coords?.[3];
+ const name = item?.[2];
+ const note = item?.[3] || null;
+
+ if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
+ places.push({ name, lat, lng, notes: note || null });
+ }
+ }
+
+ if (places.length === 0) {
+ return res.status(400).json({ error: 'No places with coordinates found in list' });
+ }
+
+ // Insert places into trip
+ const insertStmt = db.prepare(`
+ INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
+ VALUES (?, ?, ?, ?, ?, 'walking')
+ `);
+ const created: any[] = [];
+ const insertAll = db.transaction(() => {
+ for (const p of places) {
+ const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
+ const place = getPlaceWithTags(Number(result.lastInsertRowid));
+ created.push(place);
+ }
+ });
+ insertAll();
+
+ res.status(201).json({ places: created, count: created.length, listName });
+ for (const place of created) {
+ broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
+ }
+ } catch (err: unknown) {
+ console.error('[Places] Google list import error:', err instanceof Error ? err.message : err);
+ res.status(400).json({ error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' });
+ }
+});
+
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params
diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts
index d88576e1..651a479e 100644
--- a/server/src/routes/trips.ts
+++ b/server/src/routes/trips.ts
@@ -126,19 +126,12 @@ router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const archived = req.query.archived === '1' ? 1 : 0;
const userId = authReq.user.id;
- const isAdminUser = authReq.user.role === 'admin';
- const trips = isAdminUser
- ? db.prepare(`
- ${TRIP_SELECT}
- WHERE t.is_archived = :archived
- ORDER BY t.created_at DESC
- `).all({ userId, archived })
- : db.prepare(`
- ${TRIP_SELECT}
- LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
- WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
- ORDER BY t.created_at DESC
- `).all({ userId, archived });
+ const trips = db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
+ ORDER BY t.created_at DESC
+ `).all({ userId, archived });
res.json({ trips });
});
@@ -171,14 +164,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
- const isAdminUser = authReq.user.role === 'admin';
- const trip = isAdminUser
- ? db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: req.params.id })
- : db.prepare(`
- ${TRIP_SELECT}
- LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
- WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
- `).get({ userId, tripId: req.params.id });
+ const trip = db.prepare(`
+ ${TRIP_SELECT}
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
+ WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
+ `).get({ userId, tripId: req.params.id });
if (!trip) return res.status(404).json({ error: 'Trip not found' });
res.json({ trip });
});