diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index ddad80e7..7d533803 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 3.0.18 +version: 3.0.21 description: Minimal Helm chart for TREK app -appVersion: "3.0.18" +appVersion: "3.0.21" diff --git a/client/package-lock.json b/client/package-lock.json index f829027e..44ecbb48 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,16 +1,17 @@ { "name": "trek-client", - "version": "3.0.18", + "version": "3.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "3.0.18", + "version": "3.0.21", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", + "heic-to": "^1.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", @@ -5827,6 +5828,12 @@ "dev": true, "license": "MIT" }, + "node_modules/heic-to": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz", + "integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==", + "license": "LGPL-3.0" + }, "node_modules/hsl-to-hex": { "version": "1.0.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index ab8585b7..9c62e751 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "3.0.18", + "version": "3.0.21", "private": true, "type": "module", "scripts": { @@ -18,6 +18,7 @@ "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", "dexie": "^4.4.2", + "heic-to": "^1.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", diff --git a/client/src/components/Map/mapboxSetup.ts b/client/src/components/Map/mapboxSetup.ts index b3fc9071..a77fa2d0 100644 --- a/client/src/components/Map/mapboxSetup.ts +++ b/client/src/components/Map/mapboxSetup.ts @@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean { return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite' } -// Terrain is only genuinely useful for the satellite imagery styles — on -// clean flat styles like streets/light/dark it nudges route lines onto -// the DEM while our HTML markers stay at Z=0, which causes the visible -// offset when the map is pitched. Restrict terrain to satellite. +// Terrain is only genuinely useful for styles that benefit from elevation +// data. On flat vector styles (streets/light/dark) it nudges route lines +// onto the DEM while HTML markers stay at Z=0, causing a visible drift +// when the map is pitched. Satellite and Outdoors are the intended styles +// for terrain; markers are re-pinned by syncMarkerAltitudes(). export function wantsTerrain(style: string): boolean { return style === 'mapbox://styles/mapbox/satellite-v9' || style === 'mapbox://styles/mapbox/satellite-streets-v12' + || style === 'mapbox://styles/mapbox/outdoors-v12' } // 3D can be added to every style now — the standard family has it built-in diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 9e09f1ff..881ff253 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -169,7 +169,10 @@ export default function PlaceInspector({ const category = categories?.find(c => c.id === place.category_id) const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : [] - const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null + const assignmentInDay = selectedDayId + ? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null) + ?? dayAssignments.find(a => a.place?.id === place.id)) + : null const openingHours = googleDetails?.opening_hours || null const openNow = googleDetails?.open_now ?? null diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 390b05c0..8a3313f0 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1674,6 +1674,7 @@ const ar: Record = { 'journey.settings.failedToDelete': 'فشل في الحذف', 'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.photosUploaded': 'تم رفع {count} صورة', + 'journey.photosUploadFailed': 'فشل رفع بعض الصور', 'journey.photosAdded': 'تمت إضافة {count} صورة', 'journey.picker.tripPeriod': 'فترة الرحلة', 'journey.picker.dateRange': 'نطاق التاريخ', @@ -1705,6 +1706,7 @@ const ar: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟', + 'journey.editor.uploadFailed': 'فشل رفع الصور', 'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.fromGallery': 'من المعرض', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0757c3d2..cf73f2fa 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -2077,6 +2077,7 @@ const br: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?', + 'journey.editor.uploadFailed': 'Falha ao enviar fotos', 'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploading': 'Enviando...', 'journey.editor.fromGallery': 'Da galeria', @@ -2169,6 +2170,7 @@ const br: Record = { 'journey.settings.failedToDelete': 'Falha ao excluir', 'journey.entries.deleteTitle': 'Excluir entrada', 'journey.photosUploaded': '{count} fotos enviadas', + 'journey.photosUploadFailed': 'Algumas fotos não foram enviadas', 'journey.photosAdded': '{count} fotos adicionadas', 'journey.public.notFound': 'Não encontrado', 'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index a14b633d..cefbf21b 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -2082,6 +2082,7 @@ const cs: Record = { 'journey.synced.places': 'místa', 'journey.synced.synced': 'synchronizováno', 'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?', + 'journey.editor.uploadFailed': 'Nahrávání fotek selhalo', 'journey.editor.uploadPhotos': 'Nahrát fotky', 'journey.editor.uploading': 'Nahrávání...', 'journey.editor.fromGallery': 'Z galerie', @@ -2174,6 +2175,7 @@ const cs: Record = { 'journey.settings.failedToDelete': 'Smazání se nezdařilo', 'journey.entries.deleteTitle': 'Smazat záznam', 'journey.photosUploaded': '{count} fotografií nahráno', + 'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát', 'journey.photosAdded': '{count} fotografií přidáno', 'journey.public.notFound': 'Nenalezeno', 'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index cbb6d153..d287a4aa 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -2085,6 +2085,7 @@ const de: Record = { 'journey.synced.places': 'Orte', 'journey.synced.synced': 'synchronisiert', 'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?', + 'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen', 'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploading': 'Hochladen...', 'journey.editor.fromGallery': 'Aus Galerie', @@ -2181,6 +2182,7 @@ const de: Record = { 'journey.settings.failedToDelete': 'Löschen fehlgeschlagen', 'journey.entries.deleteTitle': 'Eintrag löschen', 'journey.photosUploaded': '{count} Fotos hochgeladen', + 'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden', 'journey.photosAdded': '{count} Fotos hinzugefügt', 'journey.public.notFound': 'Nicht gefunden', 'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ce8321a6..2f0bfc71 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -2111,6 +2111,7 @@ const en: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?', + 'journey.editor.uploadFailed': 'Photo upload failed', 'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploading': 'Uploading...', 'journey.editor.fromGallery': 'From Gallery', @@ -2219,6 +2220,7 @@ const en: Record = { 'journey.settings.failedToDelete': 'Failed to delete', 'journey.entries.deleteTitle': 'Delete Entry', 'journey.photosUploaded': '{count} photos uploaded', + 'journey.photosUploadFailed': 'Some photos failed to upload', 'journey.photosAdded': '{count} photos added', // Journey — Public Page diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index a66bdfb6..b9b93dc7 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -2084,6 +2084,7 @@ const es: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?', + 'journey.editor.uploadFailed': 'Error al subir fotos', 'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploading': 'Subiendo...', 'journey.editor.fromGallery': 'Desde galería', @@ -2176,6 +2177,7 @@ const es: Record = { 'journey.settings.failedToDelete': 'Error al eliminar', 'journey.entries.deleteTitle': 'Eliminar entrada', 'journey.photosUploaded': '{count} fotos subidas', + 'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir', 'journey.photosAdded': '{count} fotos añadidas', 'journey.public.notFound': 'No encontrado', 'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c7cd1605..bb84a30b 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -2078,6 +2078,7 @@ const fr: Record = { 'journey.synced.places': 'lieux', 'journey.synced.synced': 'synchronisé', 'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?', + 'journey.editor.uploadFailed': 'Échec du téléversement des photos', 'journey.editor.uploadPhotos': 'Téléverser des photos', 'journey.editor.uploading': 'Envoi...', 'journey.editor.fromGallery': 'Depuis la galerie', @@ -2170,6 +2171,7 @@ const fr: Record = { 'journey.settings.failedToDelete': 'Échec de la suppression', 'journey.entries.deleteTitle': "Supprimer l'entrée", 'journey.photosUploaded': '{count} photos téléversées', + 'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées", 'journey.photosAdded': '{count} photos ajoutées', 'journey.public.notFound': 'Introuvable', 'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index f8046fab..bfae8e1a 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -2079,6 +2079,7 @@ const hu: Record = { 'journey.synced.places': 'helyszín', 'journey.synced.synced': 'szinkronizálva', 'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?', + 'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen', 'journey.editor.uploadPhotos': 'Fotók feltöltése', 'journey.editor.uploading': 'Feltöltés...', 'journey.editor.fromGallery': 'Galériából', @@ -2171,6 +2172,7 @@ const hu: Record = { 'journey.settings.failedToDelete': 'Törlés sikertelen', 'journey.entries.deleteTitle': 'Bejegyzés törlése', 'journey.photosUploaded': '{count} fotó feltöltve', + 'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni', 'journey.photosAdded': '{count} fotó hozzáadva', 'journey.public.notFound': 'Nem található', 'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 112d17fc..a8b80d04 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -2094,6 +2094,7 @@ const id: Record = { // Journey Entry Editor 'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?', + 'journey.editor.uploadFailed': 'Gagal mengunggah foto', 'journey.editor.uploadPhotos': 'Unggah foto', 'journey.editor.uploading': 'Mengunggah...', 'journey.editor.fromGallery': 'Dari Galeri', @@ -2198,6 +2199,7 @@ const id: Record = { 'journey.settings.failedToDelete': 'Gagal menghapus', 'journey.entries.deleteTitle': 'Hapus Entri', 'journey.photosUploaded': '{count} foto diunggah', + 'journey.photosUploadFailed': 'Beberapa foto gagal diunggah', 'journey.photosAdded': '{count} foto ditambahkan', // Journey — Public Page diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 2ac5424f..21569ed9 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -2079,6 +2079,7 @@ const it: Record = { 'journey.synced.places': 'luoghi', 'journey.synced.synced': 'sincronizzato', 'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?', + 'journey.editor.uploadFailed': 'Caricamento foto non riuscito', 'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploading': 'Caricamento...', 'journey.editor.fromGallery': 'Dalla galleria', @@ -2171,6 +2172,7 @@ const it: Record = { 'journey.settings.failedToDelete': 'Eliminazione non riuscita', 'journey.entries.deleteTitle': 'Elimina voce', 'journey.photosUploaded': '{count} foto caricate', + 'journey.photosUploadFailed': 'Alcune foto non sono state caricate', 'journey.photosAdded': '{count} foto aggiunte', 'journey.public.notFound': 'Non trovato', 'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 0cb55bc1..289f2ae9 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -2078,6 +2078,7 @@ const nl: Record = { 'journey.synced.places': 'plaatsen', 'journey.synced.synced': 'gesynchroniseerd', 'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?', + 'journey.editor.uploadFailed': 'Foto uploaden mislukt', 'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploading': 'Uploaden...', 'journey.editor.fromGallery': 'Uit galerij', @@ -2170,6 +2171,7 @@ const nl: Record = { 'journey.settings.failedToDelete': 'Verwijderen mislukt', 'journey.entries.deleteTitle': 'Vermelding verwijderen', 'journey.photosUploaded': "{count} foto's geüpload", + 'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload", 'journey.photosAdded': "{count} foto's toegevoegd", 'journey.public.notFound': 'Niet gevonden', 'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 87f768a9..b0c53c00 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -2071,6 +2071,7 @@ const pl: Record = { 'journey.synced.places': 'miejsca', 'journey.synced.synced': 'zsynchronizowane', 'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?', + 'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia', 'journey.editor.uploading': 'Przesyłanie...', 'journey.editor.fromGallery': 'Z galerii', @@ -2163,6 +2164,7 @@ const pl: Record = { 'journey.settings.failedToDelete': 'Nie udało się usunąć', 'journey.entries.deleteTitle': 'Usuń wpis', 'journey.photosUploaded': '{count} zdjęć przesłanych', + 'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć', 'journey.photosAdded': '{count} zdjęć dodanych', 'journey.public.notFound': 'Nie znaleziono', 'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index f4f23fb8..3384f353 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -2078,6 +2078,7 @@ const ru: Record = { 'journey.synced.places': 'мест', 'journey.synced.synced': 'синхронизировано', 'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?', + 'journey.editor.uploadFailed': 'Не удалось загрузить фото', 'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploading': 'Загрузка...', 'journey.editor.fromGallery': 'Из галереи', @@ -2170,6 +2171,7 @@ const ru: Record = { 'journey.settings.failedToDelete': 'Не удалось удалить', 'journey.entries.deleteTitle': 'Удалить запись', 'journey.photosUploaded': '{count} фото загружено', + 'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить', 'journey.photosAdded': '{count} фото добавлено', 'journey.public.notFound': 'Не найдено', 'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index ffa564b6..6549509a 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -2078,6 +2078,7 @@ const zh: Record = { 'journey.synced.places': '个地点', 'journey.synced.synced': '已同步', 'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?', + 'journey.editor.uploadFailed': '照片上传失败', 'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploading': '上传中...', 'journey.editor.fromGallery': '从相册', @@ -2170,6 +2171,7 @@ const zh: Record = { 'journey.settings.failedToDelete': '删除失败', 'journey.entries.deleteTitle': '删除条目', 'journey.photosUploaded': '{count} 张照片已上传', + 'journey.photosUploadFailed': '部分照片上传失败', 'journey.photosAdded': '{count} 张照片已添加', 'journey.public.notFound': '未找到', 'journey.public.notFoundMessage': '此旅程不存在或链接已过期。', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 331596c5..00385ad2 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -2036,6 +2036,7 @@ const zhTw: Record = { 'journey.synced.places': '個地點', 'journey.synced.synced': '已同步', 'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?', + 'journey.editor.uploadFailed': '照片上傳失敗', 'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploading': '上傳中...', 'journey.editor.fromGallery': '從相簿', @@ -2128,6 +2129,7 @@ const zhTw: Record = { 'journey.settings.failedToDelete': '刪除失敗', 'journey.entries.deleteTitle': '刪除條目', 'journey.photosUploaded': '{count} 張照片已上傳', + 'journey.photosUploadFailed': '部分照片上傳失敗', 'journey.photosAdded': '{count} 張照片已新增', 'journey.public.notFound': '未找到', 'journey.public.notFoundMessage': '此旅程不存在或連結已過期。', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index e7aa51f9..fcd54545 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { formatLocationName } from '../utils/formatters' +import { normalizeImageFiles } from '../utils/convertHeic' import { createPortal } from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useJourneyStore } from '../store/journeyStore' @@ -29,6 +30,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' +import { getApiErrorMessage } from '../types' const GRADIENTS = [ 'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)', @@ -1027,13 +1029,14 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, if (!files?.length) return setGalleryUploading(true) try { + const normalized = await normalizeImageFiles(files) const formData = new FormData() - for (const f of files) formData.append('photos', f) + for (const f of normalized) formData.append('photos', f) await journeyApi.uploadGalleryPhotos(journeyId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() - } catch { - toast.error(t('journey.settings.coverFailed')) + } catch (err) { + toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))) } finally { setGalleryUploading(false) } @@ -2173,6 +2176,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa onDone: () => void }) { const { t } = useTranslation() + const toast = useToast() const isMobile = useIsMobile() const [title, setTitle] = useState(entry.title || '') const [story, setStory] = useState(entry.story || '') @@ -2246,7 +2250,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa if (pendingFiles.length > 0 && entryId) { const formData = new FormData() for (const f of pendingFiles) formData.append('photos', f) - await onUploadPhotos(entryId, formData) + try { + await onUploadPhotos(entryId, formData) + } catch (err) { + toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed'))) + } } // link gallery photos that were picked before save if (pendingLinkIds.length > 0 && entryId) { @@ -2265,7 +2273,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa if (!files?.length) return // Queue files locally until Save so cancel/close actually discards. This // keeps photo behavior consistent with text fields — no silent persistence. - setPendingFiles(prev => [...prev, ...Array.from(files)]) + const normalized = await normalizeImageFiles(files) + setPendingFiles(prev => [...prev, ...normalized]) } return ( diff --git a/client/src/utils/convertHeic.ts b/client/src/utils/convertHeic.ts new file mode 100644 index 00000000..6f757792 --- /dev/null +++ b/client/src/utils/convertHeic.ts @@ -0,0 +1,17 @@ +function looksLikeHeic(file: File): boolean { + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif' +} + +export async function normalizeImageFile(file: File): Promise { + if (!looksLikeHeic(file)) return file + const { isHeic, heicTo } = await import('heic-to') + if (!(await isHeic(file))) return file + const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 }) + const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg') + return new File([blob], jpegName, { type: 'image/jpeg' }) +} + +export async function normalizeImageFiles(files: FileList | File[]): Promise { + return Promise.all(Array.from(files).map(normalizeImageFile)) +} diff --git a/server/package-lock.json b/server/package-lock.json index 24482213..22da1273 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "trek-server", - "version": "3.0.18", + "version": "3.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-server", - "version": "3.0.18", + "version": "3.0.21", "dependencies": { "@modelcontextprotocol/sdk": "^1.28.0", "archiver": "^6.0.1", diff --git a/server/package.json b/server/package.json index 1d61368c..fefb4072 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "trek-server", - "version": "3.0.18", + "version": "3.0.21", "main": "src/index.ts", "scripts": { "start": "node --import tsx src/index.ts", @@ -25,6 +25,7 @@ "helmet": "^8.1.0", "jimp": "^1.6.1", "jsonwebtoken": "^9.0.2", + "ldapts": "^8.1.7", "multer": "^2.1.1", "node-cron": "^4.2.1", "nodemailer": "^8.0.5", diff --git a/server/src/app.ts b/server/src/app.ts index c03c7583..45d17b7d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser'; import path from 'node:path'; import fs from 'node:fs'; +import multer from 'multer'; import { logDebug, logWarn, logError } from './services/auditLog'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; @@ -122,7 +123,7 @@ export function createApp(): express.Application { contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'wasm-unsafe-eval'"], + scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], imgSrc: ["'self'", "data:", "blob:", "https:"], connectSrc: [ @@ -507,6 +508,10 @@ export function createApp(): express.Application { } else { console.error('Unhandled error:', err); } + if (err instanceof multer.MulterError) { + const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + return res.status(status).json({ error: err.message }); + } const status = err.statusCode || err.status || 500; // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. const message = status < 500 ? err.message : 'Internal server error'; diff --git a/server/src/mcp/oauthProvider.ts b/server/src/mcp/oauthProvider.ts index 943a515f..592fd282 100644 --- a/server/src/mcp/oauthProvider.ts +++ b/server/src/mcp/oauthProvider.ts @@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = { if (params.state) qs.set('state', params.state); if (params.resource) qs.set('resource', params.resource.href); - res.redirect(302, `/oauth/consent?${qs.toString()}`); + const base = getMcpSafeUrl().replace(/\/+$/, ''); + res.redirect(302, `${base}/oauth/consent?${qs.toString()}`); }, // Not called because skipLocalPkceValidation = true. diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index d124ad44..629ad16a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -13,6 +13,7 @@ import { validateInviteToken, registerUser, loginUser, + ldapLoginUser, getCurrentUser, changePassword, deleteAccount, @@ -150,7 +151,7 @@ router.post('/register', authLimiter, (req: Request, res: Response) => { router.post('/login', authLimiter, async (req: Request, res: Response) => { const started = Date.now(); - const result = loginUser(req.body); + const result = await ldapLoginUser(req.body); if (result.auditAction) { writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index 1336bd50..b655d7ce 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) = // ── Photos (prefix /photos and /entries — before /:id) ─────────────────── -router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => { +router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { const authReq = req as AuthRequest; const files = req.files as Express.Multer.File[]; if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); @@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon // ── Gallery (prefix /:id/gallery — before /:id) ────────────────────────── // Upload photos directly to the journey gallery (no entry association) -router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => { +router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { const authReq = req as AuthRequest; const files = req.files as Express.Multer.File[]; if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 794fe5c1..35f75623 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -432,7 +432,7 @@ export function loginUser(body: { auditAction?: string; auditDetails?: Record; } { - if (isOidcOnlyMode()) { + if (isOidcOnlyMode() && !process.env.LDAP_URL) { return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; } @@ -489,6 +489,82 @@ export function loginUser(body: { }; } + +// --------------------------------------------------------------------------- +// LDAP login — async wrapper (FreeIPA / OpenLDAP) +// --------------------------------------------------------------------------- +export async function ldapLoginUser(body: { + email?: string; + password?: string; +}): Promise> { + const { getLdapConfig, ldapAuthenticate } = await import('./ldapService'); + + if (!getLdapConfig()) { + return loginUser(body); + } + + const { email: usernameOrEmail, password } = body; + if (!usernameOrEmail || !password) { + return { error: 'Email and password are required', status: 400 }; + } + + const username = usernameOrEmail.includes('@') + ? usernameOrEmail.split('@')[0] + : usernameOrEmail; + + let ldapUser; + try { + ldapUser = await ldapAuthenticate(username, password); + } catch (err) { + console.error('[LDAP] Authentication error:', err); + return { error: 'LDAP authentication failed', status: 502 }; + } + + if (!ldapUser) { + // User nicht in LDAP — lokalen Login versuchen (z.B. lokaler Admin) + return loginUser(body); + } + + const role: 'admin' | 'user' = ldapUser.isAdmin ? 'admin' : 'user'; + let user = db.prepare( + 'SELECT * FROM users WHERE LOWER(email) = LOWER(?)' + ).get(ldapUser.email) as User | undefined; + + if (user) { + if (user.role !== role) { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, user.id); + user = { ...user, role } as User; + } + } else { + let uname = ldapUser.uid.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user'; + const conflict = db.prepare( + 'SELECT id FROM users WHERE LOWER(username) = LOWER(?)' + ).get(uname); + if (conflict) uname = uname + '_' + String(Date.now() % 10000); + + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)' + ).run(uname, ldapUser.email, '!ldap', role, process.env.APP_VERSION || '0.0.0'); + + user = db.prepare('SELECT * FROM users WHERE id = ?').get( + Number(result.lastInsertRowid) + ) as User; + } + + db.prepare( + 'UPDATE users SET login_count = login_count + 1, last_login = CURRENT_TIMESTAMP WHERE id = ?' + ).run(user.id); + + const token = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + return { + token, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + auditUserId: Number(user.id), + auditAction: 'user.login', + auditDetails: { method: 'ldap', username }, + }; +} // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- diff --git a/server/src/services/ldapService.ts b/server/src/services/ldapService.ts new file mode 100644 index 00000000..7e5c0a3b --- /dev/null +++ b/server/src/services/ldapService.ts @@ -0,0 +1,99 @@ +import { Client, InvalidCredentialsError } from 'ldapts'; +import fs from 'node:fs'; + +export interface LdapConfig { + url: string; + bindDn: string; + bindPassword: string; + searchBase: string; + searchFilter: string; + adminGroup: string; + allowGroup: string; + tlsCa?: string; +} + +export interface LdapUser { + dn: string; + uid: string; + email: string; + displayName: string; + isAdmin: boolean; +} + +export function getLdapConfig(): LdapConfig | null { + if (!process.env.LDAP_URL) return null; + return { + url: process.env.LDAP_URL, + bindDn: process.env.LDAP_BIND_DN || '', + bindPassword: process.env.LDAP_BIND_PW || '', + searchBase: process.env.LDAP_BASE || '', + searchFilter: process.env.LDAP_FILTER || '(uid={{username}})', + adminGroup: process.env.LDAP_ADMIN_GROUP || '', + allowGroup: process.env.LDAP_ALLOWED_GROUP, + tlsCa: process.env.LDAP_TLS_CA, + }; +} + +export async function ldapAuthenticate( + username: string, + password: string, +): Promise { + const config = getLdapConfig(); + if (!config) return null; + + const tlsOptions = config.tlsCa + ? { ca: [fs.readFileSync(config.tlsCa)] } + : undefined; + + const client = new Client({ url: config.url, tlsOptions }); + + try { + await client.bind(config.bindDn, config.bindPassword); + + const filter = config.searchFilter.replace('{{username}}', username); + const { searchEntries } = await client.search(config.searchBase, { + scope: 'sub', + filter, + attributes: ['uid', 'mail', 'cn', 'memberOf'], + }); + + if (!searchEntries.length) return null; + + const entry = searchEntries[0]; + const dn = entry.dn; + + try { + await client.bind(dn, password); + } catch (err) { + if (err instanceof InvalidCredentialsError) return null; + throw err; + } + + const raw = entry['memberOf']; + const groups: string[] = Array.isArray(raw) + ? raw.map(String) + : raw ? [String(raw)] : []; + + const isAdmin = config.adminGroup + ? groups.some(g => g.toLowerCase() === config.adminGroup.toLowerCase()) + : false; + + // Access control: Only members of the allowed group are permitted to enter + // Exception: Admins are always allowed in + const allowedGroup = process.config.allowGroup; + if (allowedGroup && !isAdmin) { + const allowed = groups.some(g => g.toLowerCase() === allowedGroup.toLowerCase()); + if (!allowed) return null; + } + + return { + dn, + uid: String(entry['uid'] || username), + email: String(entry['mail'] || ''), + displayName: String(entry['cn'] || username), + isAdmin, + }; + } finally { + await client.unbind(); + } +} diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index 00151fe2..c336135c 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -1,6 +1,7 @@ import { db } from '../db/database'; import { decrypt_api_key } from './apiKeyCrypto'; import { checkSsrf } from '../utils/ssrfGuard'; +import { getAppUrl } from './notifications'; // ── Google API call counter ─────────────────────────────────────────────────── @@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; } function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { googleApiCallCount++; console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); - return fetch(endpoint, init); + const referer = process.env.APP_URL ? getAppUrl() : undefined; + return fetch(endpoint, { + ...init, + headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record ?? {}) }, + }); } // ── Interfaces ─────────────────────────────────────────────────────────────── diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index ecc9a3bc..94b6b34c 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -316,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record // ── Email HTML builder ───────────────────────────────────────────────────── -export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string { +export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string { const s = I18N[lang] || I18N.en; const appUrl = getAppUrl(); const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '')); const safeSubject = escapeHtml(subject); - const safeBody = escapeHtml(body); + const safeBody = rawBody ? body : escapeHtml(body); return ` @@ -396,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,

${safeExpiry}

${safeIgnore}

`; - return buildEmailHtml(subject, block, lang); + return buildEmailHtml(subject, block, lang, undefined, true); } /** diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index 5dd39145..ceea0ca7 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -37,6 +37,7 @@ Each entry corresponds to a day in your journey. The entry editor provides: - **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold. - **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views. + > **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.). - **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry. - **Tags** — free-form labels (e.g. "hidden gem", "best meal"). - **Location** — pin the entry to a map location. diff --git a/wiki/Photo-Providers.md b/wiki/Photo-Providers.md index d6754e00..5be8d8b5 100644 --- a/wiki/Photo-Providers.md +++ b/wiki/Photo-Providers.md @@ -44,6 +44,7 @@ When generating the API key in Immich (**Account Settings → API Keys**), grant | `asset.read` | Read photo metadata and search results | | `asset.view` | Load thumbnails and preview images | | `album.read` | List owned + shared albums and their contents | +| `asset.download` | Download the assets | | `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library | TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed. @@ -94,4 +95,4 @@ Once a provider is connected, you can browse and attach photos to your trips. Se ## See also - [Admin-Addons](Admin-Addons) -- [Internal-Network-Access](Internal-Network-Access) \ No newline at end of file +- [Internal-Network-Access](Internal-Network-Access) diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index efc49453..8a336294 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -21,6 +21,8 @@ Type in the search box at the top of the form. After 2 or more characters, with When a key is present, the autocomplete uses the Google Places API, which can return ratings, opening hours, photos, and phone numbers from Google's database. +> **API key restrictions:** TREK calls the Google Places API from the server, not the browser. If you apply **HTTP referrers** restrictions to your key in Google Cloud Console, you must also set `APP_URL` in your environment — TREK sends it as the `Referer` header on every outbound Google API request. Without it, Google will reject all server-side calls with `REQUEST_DENIED`. For server-side deployments, **IP address** restrictions are simpler and require no extra configuration. See [Troubleshooting](Troubleshooting) if photos are missing after adding a key. + ### Without a Google Maps API key TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box explaining that OpenStreetMap is in use and that photos, ratings, and opening hours are unavailable. Results include name, address, and coordinates. diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 7b7a6d03..dae924c8 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -223,6 +223,45 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default). --- +## Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured) + +**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are: + +1. **HTTP referrer restriction on the API key.** Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a `Referer` header derived from `APP_URL`. If `APP_URL` is not set, the fallback is `http://localhost:`, which will not match any domain whitelist in GCP. + +2. **Wrong key restriction type.** API keys restricted by **HTTP referrers** are designed for browser-side JavaScript. For a self-hosted server application, use **IP address** restrictions instead — add the public IP of your TREK server and no `APP_URL` configuration is needed. + +3. **Places API (New) not enabled.** The key must have **Places API (New)** enabled in Google Cloud Console under APIs & Services → Enabled APIs. Enabling only the legacy Places API is not sufficient. + +4. **Billing not set up.** Google requires a billing account to be linked to the project even within the free tier. Without it, photo and details requests return `REQUEST_DENIED`. + +**Fix for HTTP referrer restriction:** + +Set `APP_URL` to the public URL of your instance and add that URL (or its domain with a wildcard, e.g. `https://trek.example.com/*`) to the allowed referrers in GCP: + +```yaml +environment: + - APP_URL=https://trek.example.com +``` + +**Fix for wrong restriction type:** + +Switch the key's "Application restrictions" from **HTTP referrers** to **IP addresses** in Google Cloud Console, and add your server's public IP. No `APP_URL` change needed. + +**Verifying the issue:** + +Run the following curl command using your key to check whether Google returns photo references: + +```bash +curl "https://places.googleapis.com/v1/places/" \ + -H "X-Goog-Api-Key: YOUR_API_KEY" \ + -H "X-Goog-FieldMask: photos" +``` + +If the response is `{}` or `{"error": {...}}`, the key or its restrictions are blocking the request. If it returns a `photos` array, the key is valid and the issue is elsewhere. + +--- + ## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts **Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.