diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9c414d0..790e341 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/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 2b2b368..17cbbe8 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 1974c6f..dbb14b1 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 5ae2943..f4e7c69 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 a57b3b7..3b69a1c 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 f998bfd..eb0ffa4 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 15aba3b..989e8cc 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 48dd65a..de28bef 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 3550b4f..c335ae4 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 3815d73..c183678 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 53acacc..bc5fac4 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 7f8a985..8285963 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 cd23c2d..5454458 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 0e887df..9608ceb 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 2f3b214..5dd4b10 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