feat: add Google Maps list import

Import places from shared Google Maps lists via URL.
Button in places sidebar next to GPX import opens a modal
where users can paste a shared list link. Server fetches
list data from Google Maps and creates places with name,
coordinates and notes. i18n keys added for all 12 languages.

Closes #205
This commit is contained in:
Maurice
2026-04-01 10:13:35 +02:00
parent 8c85ea3644
commit 040840917c
15 changed files with 285 additions and 25 deletions

View File

@@ -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 = {

View File

@@ -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<Set<string>>(new Set())
@@ -105,18 +126,32 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
</button>}
{canEditPlaces && <>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
border: '1px dashed var(--border-primary)', background: 'none',
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
</button>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
border: '1px dashed var(--border-primary)', background: 'none',
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
</button>
<button
onClick={() => setGoogleListOpen(true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
border: '1px dashed var(--border-primary)', background: 'none',
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
</button>
</div>
</>}
{/* Filter-Tabs */}
@@ -366,6 +401,64 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
</div>,
document.body
)}
{googleListOpen && ReactDOM.createPortal(
<div
onClick={() => { 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 }}
>
<div
onClick={e => 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)' }}
>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
{t('places.importGoogleList')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
{t('places.googleListHint')}
</div>
<input
type="text"
value={googleListUrl}
onChange={e => 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',
}}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
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={handleGoogleListImport}
disabled={!googleListUrl.trim() || googleListLoading}
style={{
padding: '8px 16px', borderRadius: 10, border: 'none',
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
fontFamily: 'inherit',
}}
>
{googleListLoading ? t('common.loading') : t('common.import')}
</button>
</div>
</div>
</div>,
document.body
)}
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)

View File

@@ -10,6 +10,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.edit': 'تعديل',
'common.add': 'إضافة',
'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد',
'common.error': 'خطأ',
'common.back': 'رجوع',
'common.all': 'الكل',
@@ -783,9 +784,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// 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': 'الكل',

View File

@@ -6,6 +6,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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',

View File

@@ -6,6 +6,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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é',

View File

@@ -6,6 +6,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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',

View File

@@ -6,6 +6,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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',

View File

@@ -6,6 +6,7 @@ const es: Record<string, string> = {
'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<string, string> = {
// 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',

View File

@@ -6,6 +6,7 @@ const fr: Record<string, string> = {
'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<string, string> = {
// 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',

View File

@@ -6,6 +6,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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',

View File

@@ -6,6 +6,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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<string, string | { name: string; category: string }[]> = {
// 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',

View File

@@ -6,6 +6,7 @@ const nl: Record<string, string> = {
'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<string, string> = {
// 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',

View File

@@ -6,6 +6,7 @@ const ru: Record<string, string> = {
'common.edit': 'Редактировать',
'common.add': 'Добавить',
'common.loading': 'Загрузка...',
'common.import': 'Импорт',
'common.error': 'Ошибка',
'common.back': 'Назад',
'common.all': 'Все',
@@ -780,9 +781,13 @@ const ru: Record<string, string> = {
// 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': 'Все',

View File

@@ -6,6 +6,7 @@ const zh: Record<string, string> = {
'common.edit': '编辑',
'common.add': '添加',
'common.loading': '加载中...',
'common.import': '导入',
'common.error': '错误',
'common.back': '返回',
'common.all': '全部',
@@ -780,9 +781,13 @@ const zh: Record<string, string> = {
// 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': '全部',

View File

@@ -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