From b3571f391a50e766ac7d7ba9c5516ef1b56b38c6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 19:58:13 +0200 Subject: [PATCH 01/10] Fix skeleton entry deletion and add hide suggestions toggle (#619) - Revert filled skeleton entries back to skeleton on delete instead of permanently removing them - Add per-user hide_skeletons preference on journey_contributors (migration 99) - Add PATCH /journeys/:id/preferences endpoint for toggling skeleton visibility - Add Eye/EyeOff toggle button with custom tooltip in journey detail header - Filter skeleton entries from timeline when hidden - Add i18n keys for all 14 languages --- client/src/api/client.ts | 3 ++ client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/pl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/i18n/translations/zhTw.ts | 2 + client/src/pages/JourneyDetailPage.tsx | 25 ++++++++++-- client/src/store/journeyStore.ts | 1 + server/src/db/migrations.ts | 4 ++ server/src/routes/journey.ts | 9 +++++ server/src/services/journeyService.ts | 34 +++++++++++++++- .../unit/services/journeyService.test.ts | 40 +++++++++++++++++++ 21 files changed, 139 insertions(+), 5 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 791c2a5e..6d981112 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -322,6 +322,9 @@ export const journeyApi = { updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data), removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data), + // Preferences + updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data), + // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index ac834d22..a15e801d 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1557,6 +1557,8 @@ const ar: Record = { 'journey.detail.backToJourney': 'العودة للمجلة', 'journey.detail.day': 'اليوم {number}', 'journey.detail.places': 'أماكن', + 'journey.skeletons.show': 'إظهار الاقتراحات', + 'journey.skeletons.hide': 'إخفاء الاقتراحات', // Journey — Invite 'journey.invite.role': 'الدور', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 512095f1..9bb4d00e 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1895,6 +1895,8 @@ const br: Record = { 'journey.stats.entries': 'Entradas', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Lugares', + 'journey.skeletons.show': 'Mostrar sugestões', + 'journey.skeletons.hide': 'Ocultar sugestões', 'journey.verdict.lovedIt': 'Adorei', 'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.synced.places': 'lugares', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index f5959522..dc07ce84 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1900,6 +1900,8 @@ const cs: Record = { 'journey.stats.entries': 'Záznamy', 'journey.stats.photos': 'Fotky', 'journey.stats.places': 'Místa', + 'journey.skeletons.show': 'Zobrazit návrhy', + 'journey.skeletons.hide': 'Skrýt návrhy', 'journey.verdict.lovedIt': 'Skvělé', 'journey.verdict.couldBeBetter': 'Mohlo by být lepší', 'journey.synced.places': 'místa', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 3e4f2f11..6aa8fc92 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1901,6 +1901,8 @@ const de: Record = { 'journey.stats.entries': 'Einträge', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Orte', + 'journey.skeletons.show': 'Vorschläge anzeigen', + 'journey.skeletons.hide': 'Vorschläge ausblenden', 'journey.verdict.lovedIt': 'Toll', 'journey.verdict.couldBeBetter': 'Verbesserungswürdig', 'journey.synced.places': 'Orte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 91117374..b331ff76 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1906,6 +1906,8 @@ const en: Record = { 'journey.stats.entries': 'Entries', 'journey.stats.photos': 'Photos', 'journey.stats.places': 'Places', + 'journey.skeletons.show': 'Show suggestions', + 'journey.skeletons.hide': 'Hide suggestions', // Journey Detail — Verdict 'journey.verdict.lovedIt': 'Loved it', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c37097e9..871f53a6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1902,6 +1902,8 @@ const es: Record = { 'journey.stats.entries': 'Entradas', 'journey.stats.photos': 'Fotos', 'journey.stats.places': 'Lugares', + 'journey.skeletons.show': 'Mostrar sugerencias', + 'journey.skeletons.hide': 'Ocultar sugerencias', 'journey.verdict.lovedIt': 'Me encantó', 'journey.verdict.couldBeBetter': 'Podría mejorar', 'journey.synced.places': 'lugares', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c00172c5..f6e4f7a7 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1896,6 +1896,8 @@ const fr: Record = { 'journey.stats.entries': 'Entrées', 'journey.stats.photos': 'Photos', 'journey.stats.places': 'Lieux', + 'journey.skeletons.show': 'Afficher les suggestions', + 'journey.skeletons.hide': 'Masquer les suggestions', 'journey.verdict.lovedIt': 'Adoré', 'journey.verdict.couldBeBetter': 'Pourrait être mieux', 'journey.synced.places': 'lieux', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 18117520..d531e555 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1897,6 +1897,8 @@ const hu: Record = { 'journey.stats.entries': 'Bejegyzések', 'journey.stats.photos': 'Fotók', 'journey.stats.places': 'Helyszínek', + 'journey.skeletons.show': 'Javaslatok megjelenítése', + 'journey.skeletons.hide': 'Javaslatok elrejtése', 'journey.verdict.lovedIt': 'Imádtam', 'journey.verdict.couldBeBetter': 'Lehetne jobb', 'journey.synced.places': 'helyszín', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 7637ecb5..f299587a 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1897,6 +1897,8 @@ const it: Record = { 'journey.stats.entries': 'Voci', 'journey.stats.photos': 'Foto', 'journey.stats.places': 'Luoghi', + 'journey.skeletons.show': 'Mostra suggerimenti', + 'journey.skeletons.hide': 'Nascondi suggerimenti', 'journey.verdict.lovedIt': 'Adorato', 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', 'journey.synced.places': 'luoghi', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 37d1ea2a..a1b64958 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1896,6 +1896,8 @@ const nl: Record = { 'journey.stats.entries': 'Vermeldingen', 'journey.stats.photos': 'Foto\'s', 'journey.stats.places': 'Plaatsen', + 'journey.skeletons.show': 'Suggesties tonen', + 'journey.skeletons.hide': 'Suggesties verbergen', 'journey.verdict.lovedIt': 'Geweldig', 'journey.verdict.couldBeBetter': 'Kan beter', 'journey.synced.places': 'plaatsen', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index e7bc8507..4c741397 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1889,6 +1889,8 @@ const pl: Record = { 'journey.stats.entries': 'Wpisy', 'journey.stats.photos': 'Zdjęcia', 'journey.stats.places': 'Miejsca', + 'journey.skeletons.show': 'Pokaż sugestie', + 'journey.skeletons.hide': 'Ukryj sugestie', 'journey.verdict.lovedIt': 'Świetne', 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', 'journey.synced.places': 'miejsca', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 23b02a94..d7e4e9cd 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1896,6 +1896,8 @@ const ru: Record = { 'journey.stats.entries': 'Записей', 'journey.stats.photos': 'Фото', 'journey.stats.places': 'Мест', + 'journey.skeletons.show': 'Показать предложения', + 'journey.skeletons.hide': 'Скрыть предложения', 'journey.verdict.lovedIt': 'Понравилось', 'journey.verdict.couldBeBetter': 'Могло быть лучше', 'journey.synced.places': 'мест', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 53b1e24d..2a8d43d1 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1896,6 +1896,8 @@ const zh: Record = { 'journey.stats.entries': '条目', 'journey.stats.photos': '照片', 'journey.stats.places': '地点', + 'journey.skeletons.show': '显示建议', + 'journey.skeletons.hide': '隐藏建议', 'journey.verdict.lovedIt': '非常喜欢', 'journey.verdict.couldBeBetter': '有待改进', 'journey.synced.places': '个地点', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 334fca61..78231de5 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1856,6 +1856,8 @@ const zhTw: Record = { 'journey.stats.entries': '條目', 'journey.stats.photos': '照片', 'journey.stats.places': '地點', + 'journey.skeletons.show': '顯示建議', + 'journey.skeletons.hide': '隱藏建議', 'journey.verdict.lovedIt': '非常喜歡', 'journey.verdict.couldBeBetter': '有待改進', 'journey.synced.places': '個地點', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 989ecfd4..e8307f11 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -18,7 +18,7 @@ import { Clock, Package, Image, ChevronRight, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, Laugh, Smile, Meh, Annoyed, Frown, - Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, + Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff, } from 'lucide-react' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' @@ -92,11 +92,16 @@ export default function JourneyDetailPage() { const [showAddTrip, setShowAddTrip] = useState(false) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [showSettings, setShowSettings] = useState(false) + const [hideSkeletons, setHideSkeletons] = useState(false) useEffect(() => { if (id) loadJourney(Number(id)).catch(() => {}) }, [id]) + useEffect(() => { + if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons) + }, [current?.hide_skeletons]) + useEffect(() => { if (notFound) { toast.error(t('journey.notFound')) @@ -193,7 +198,7 @@ export default function JourneyDetailPage() { ) } - const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') + const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() @@ -243,7 +248,21 @@ export default function JourneyDetailPage() {
- +
+ + + {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')} + +
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index e1e1a16f..e0a8f22b 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -82,6 +82,7 @@ export interface JourneyDetail extends Journey { trips: JourneyTrip[] contributors: JourneyContributor[] stats: { entries: number; photos: number; cities: number } + hide_skeletons?: boolean } interface JourneyState { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7059391c..4238cf48 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1574,6 +1574,10 @@ function runMigrations(db: Database.Database): void { db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); } }, + // Migration 99: hide_skeletons per-user setting on journey_contributors + () => { + try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index edb9a674..452df039 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -279,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res res.json({ success: true }); }); +// ── User Preferences ───────────────────────────────────────────────────── + +router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body); + if (!result) return res.status(403).json({ error: 'Not allowed' }); + res.json(result); +}); + // ── Share Link ──────────────────────────────────────────────────────────── router.get('/:id/share-link', authenticate, (req: Request, res: Response) => { diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index 168677ab..593bc20a 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -161,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) { const photoCount = photos.length; const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; + const userPrefs = db.prepare( + 'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?' + ).get(journeyId, userId) as { hide_skeletons: number } | undefined; + return { ...journey, entries: enrichedEntries, trips, contributors, stats: { entries: entryCount, photos: photoCount, cities: cities.length }, + hide_skeletons: !!(userPrefs?.hide_skeletons), }; } @@ -197,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{ return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; } +export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { + if (!canAccessJourney(journeyId, userId)) return null; + if (data.hide_skeletons !== undefined) { + db.prepare( + 'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?' + ).run(data.hide_skeletons ? 1 : 0, journeyId, userId); + } + const row = db.prepare( + 'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?' + ).get(journeyId, userId) as { hide_skeletons: number }; + return { hide_skeletons: !!row.hide_skeletons }; +} + export function deleteJourney(journeyId: number, userId: number): boolean { if (!isOwner(journeyId, userId)) return false; db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); @@ -567,7 +585,20 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool // delete photos along with the entry — no more orphan Gallery entries db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId); - db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + + if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') { + // Revert filled entry back to skeleton instead of deleting + db.prepare(` + UPDATE journey_entries + SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL, + visibility = 'private', updated_at = ? + WHERE id = ? + `).run(ts(), entryId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid); + } else { + db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); + } // clean up any empty Gallery entries in this journey db.prepare(` @@ -575,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos) `).run(entry.journey_id); - broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid); return true; } diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index 50d3ea4b..44c6290d 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -565,6 +565,46 @@ describe('deleteEntry', () => { expect(deleteEntry(entry.id, viewer.id)).toBe(false); }); + + it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' }); + + // Create a filled entry that originated from a trip skeleton + const now = Date.now(); + testDb.prepare(` + INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?) + `).run(journey.id, trip.id, place.id, user.id, now, now); + const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any; + + const result = deleteEntry(entry.id, user.id); + expect(result).toBe(true); + + // Entry should still exist but reverted to skeleton + const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any; + expect(reverted).toBeDefined(); + expect(reverted.type).toBe('skeleton'); + expect(reverted.story).toBeNull(); + expect(reverted.mood).toBeNull(); + expect(reverted.source_trip_id).toBe(trip.id); + expect(reverted.source_place_id).toBe(place.id); + expect(reverted.title).toBe('Tokyo Tower'); + }); + + it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' }); + + const result = deleteEntry(entry.id, user.id); + expect(result).toBe(true); + + const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id); + expect(row).toBeUndefined(); + }); }); // -- Photos ------------------------------------------------------------------- From efeff0ba9e047aa695bc547dee728ff76006ee16 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:12:15 +0200 Subject: [PATCH 02/10] Add upload loading indicator for journey photos (#622) - Show spinner and "Uploading..." text on photo upload button in entry editor - Show spinner on gallery view upload button during upload - Disable upload buttons while upload is in progress - Add i18n key journey.editor.uploading for all 14 languages --- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + client/src/pages/JourneyDetailPage.tsx | 55 +++++++++++++++++--------- 15 files changed, 50 insertions(+), 19 deletions(-) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index a15e801d..e77b0d47 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1569,6 +1569,7 @@ const ar: Record = { // Journey Entry Editor 'journey.editor.uploadPhotos': 'رفع صور', + 'journey.editor.uploading': '...جارٍ الرفع', 'journey.editor.fromGallery': 'من المعرض', 'journey.editor.addAnother': 'إضافة آخر', 'journey.editor.makeFirst': 'جعله الأول', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 9bb4d00e..448815e2 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1902,6 +1902,7 @@ const br: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.uploadPhotos': 'Enviar fotos', + 'journey.editor.uploading': 'Enviando...', 'journey.editor.fromGallery': 'Da galeria', 'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas', 'journey.editor.writeStory': 'Escreva sua história...', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index dc07ce84..f9250361 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1907,6 +1907,7 @@ const cs: Record = { 'journey.synced.places': 'místa', 'journey.synced.synced': 'synchronizováno', 'journey.editor.uploadPhotos': 'Nahrát fotky', + 'journey.editor.uploading': 'Nahrávání...', 'journey.editor.fromGallery': 'Z galerie', 'journey.editor.allPhotosAdded': 'Všechny fotky již přidány', 'journey.editor.writeStory': 'Napište svůj příběh...', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 6aa8fc92..2ff65611 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1908,6 +1908,7 @@ const de: Record = { 'journey.synced.places': 'Orte', 'journey.synced.synced': 'synchronisiert', 'journey.editor.uploadPhotos': 'Fotos hochladen', + 'journey.editor.uploading': 'Hochladen...', 'journey.editor.fromGallery': 'Aus Galerie', 'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt', 'journey.editor.writeStory': 'Erzähle deine Geschichte...', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b331ff76..886c4213 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1919,6 +1919,7 @@ const en: Record = { // Journey Entry Editor 'journey.editor.uploadPhotos': 'Upload photos', + 'journey.editor.uploading': 'Uploading...', 'journey.editor.fromGallery': 'From Gallery', 'journey.editor.allPhotosAdded': 'All photos already added', 'journey.editor.writeStory': 'Write your story...', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 871f53a6..5f3c4b17 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1909,6 +1909,7 @@ const es: Record = { 'journey.synced.places': 'lugares', 'journey.synced.synced': 'sincronizado', 'journey.editor.uploadPhotos': 'Subir fotos', + 'journey.editor.uploading': 'Subiendo...', 'journey.editor.fromGallery': 'Desde galería', 'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas', 'journey.editor.writeStory': 'Escribe tu historia...', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index f6e4f7a7..88bda0bd 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1903,6 +1903,7 @@ const fr: Record = { 'journey.synced.places': 'lieux', 'journey.synced.synced': 'synchronisé', 'journey.editor.uploadPhotos': 'Téléverser des photos', + 'journey.editor.uploading': 'Envoi...', 'journey.editor.fromGallery': 'Depuis la galerie', 'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées', 'journey.editor.writeStory': 'Écrivez votre histoire...', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index d531e555..4fdc8f31 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1904,6 +1904,7 @@ const hu: Record = { 'journey.synced.places': 'helyszín', 'journey.synced.synced': 'szinkronizálva', 'journey.editor.uploadPhotos': 'Fotók feltöltése', + 'journey.editor.uploading': 'Feltöltés...', 'journey.editor.fromGallery': 'Galériából', 'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva', 'journey.editor.writeStory': 'Írd meg a történeted...', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index f299587a..a781535f 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1904,6 +1904,7 @@ const it: Record = { 'journey.synced.places': 'luoghi', 'journey.synced.synced': 'sincronizzato', 'journey.editor.uploadPhotos': 'Carica foto', + 'journey.editor.uploading': 'Caricamento...', 'journey.editor.fromGallery': 'Dalla galleria', 'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte', 'journey.editor.writeStory': 'Scrivi la tua storia...', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index a1b64958..a8726b42 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1903,6 +1903,7 @@ const nl: Record = { 'journey.synced.places': 'plaatsen', 'journey.synced.synced': 'gesynchroniseerd', 'journey.editor.uploadPhotos': 'Foto\'s uploaden', + 'journey.editor.uploading': 'Uploaden...', 'journey.editor.fromGallery': 'Uit galerij', 'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd', 'journey.editor.writeStory': 'Schrijf je verhaal...', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4c741397..45c83dde 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1896,6 +1896,7 @@ const pl: Record = { 'journey.synced.places': 'miejsca', 'journey.synced.synced': 'zsynchronizowane', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia', + 'journey.editor.uploading': 'Przesyłanie...', 'journey.editor.fromGallery': 'Z galerii', 'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane', 'journey.editor.writeStory': 'Napisz swoją historię...', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index d7e4e9cd..8fc08565 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1903,6 +1903,7 @@ const ru: Record = { 'journey.synced.places': 'мест', 'journey.synced.synced': 'синхронизировано', 'journey.editor.uploadPhotos': 'Загрузить фото', + 'journey.editor.uploading': 'Загрузка...', 'journey.editor.fromGallery': 'Из галереи', 'journey.editor.allPhotosAdded': 'Все фото уже добавлены', 'journey.editor.writeStory': 'Напишите свою историю...', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 2a8d43d1..d998b5c0 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1903,6 +1903,7 @@ const zh: Record = { 'journey.synced.places': '个地点', 'journey.synced.synced': '已同步', 'journey.editor.uploadPhotos': '上传照片', + 'journey.editor.uploading': '上传中...', 'journey.editor.fromGallery': '从相册', 'journey.editor.allPhotosAdded': '所有照片已添加', 'journey.editor.writeStory': '写下你的故事...', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 78231de5..76612034 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1863,6 +1863,7 @@ const zhTw: Record = { 'journey.synced.places': '個地點', 'journey.synced.synced': '已同步', 'journey.editor.uploadPhotos': '上傳照片', + 'journey.editor.uploading': '上傳中...', 'journey.editor.fromGallery': '從相簿', 'journey.editor.allPhotosAdded': '所有照片已新增', 'journey.editor.writeStory': '寫下你的故事...', diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index e8307f11..ba69b1f7 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -772,6 +772,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres const [showPicker, setShowPicker] = useState(false) const [pickerProvider, setPickerProvider] = useState(null) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) + const [galleryUploading, setGalleryUploading] = useState(false) const toast = useToast() // check which providers are enabled AND connected for the current user @@ -816,27 +817,28 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres const handleGalleryUpload = async (e: React.ChangeEvent) => { const files = e.target.files if (!files?.length) return - // find existing "Gallery" entry or create one - let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') - let entryId = galleryEntry?.id - if (!entryId) { - try { + setGalleryUploading(true) + try { + // find existing "Gallery" entry or create one + let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') + let entryId = galleryEntry?.id + if (!entryId) { const entry = await journeyApi.createEntry(journeyId, { title: t('journey.share.gallery'), entry_date: new Date().toISOString().split('T')[0], type: 'entry', }) entryId = entry.id - } catch { return } - } - const formData = new FormData() - for (const f of files) formData.append('photos', f) - try { + } + const formData = new FormData() + for (const f of files) formData.append('photos', f) await journeyApi.uploadPhotos(entryId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() } catch { toast.error(t('journey.settings.coverFailed')) + } finally { + setGalleryUploading(false) } e.target.value = '' } @@ -874,10 +876,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
{availableProviders.map(p => ( {galleryPhotos.length > 0 && (
{clamped && !expanded && ( + )} + {expanded && ( + )} From 00e96baf0e66f925e2762d2d675eb35b795f01cf Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:21:57 +0200 Subject: [PATCH 04/10] Fix Stadia Maps 401 on journey and atlas maps (#640) - Add referrerPolicy to JourneyMap TileLayer (matching trip planner behavior) - Add referrerPolicy to AtlasPage TileLayer (same issue) - Stadia Maps requires the referrer header for domain validation --- client/src/components/Journey/JourneyMap.tsx | 3 ++- client/src/pages/AtlasPage.tsx | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index 082c4a4f..88b08d0b 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -168,7 +168,8 @@ const JourneyMap = forwardRef(function JourneyMap( L.tileLayer(mapTileUrl || defaultTile, { maxZoom: 18, attribution: '© OpenStreetMap', - }).addTo(map) + referrerPolicy: 'strict-origin-when-cross-origin', + } as any).addTo(map) const items = buildMarkerItems(entries) itemsRef.current = items diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 0195da71..4dc4047e 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -296,8 +296,9 @@ export default function AtlasPage(): React.ReactElement { updateWhenIdle: false, tileSize: 256, zoomOffset: 0, - crossOrigin: true - }).addTo(map) + crossOrigin: true, + referrerPolicy: 'strict-origin-when-cross-origin', + } as any).addTo(map) // Preload adjacent zoom level tiles L.tileLayer(tileUrl, { @@ -306,6 +307,7 @@ export default function AtlasPage(): React.ReactElement { opacity: 0, tileSize: 256, crossOrigin: true, + referrerPolicy: 'strict-origin-when-cross-origin', }).addTo(map) // Custom pane for region layer — above overlay (z-index 400) From e695e0f62d3f482f76df2671e1c966bf91b1f889 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:27:44 +0200 Subject: [PATCH 05/10] Move memories providers under Journey addon in admin settings (#629) - Remove memories providers from trip addons section - Show Immich/Synology as sub-items under the Journey global addon - Same pattern as bag tracking under packing list --- client/src/components/Admin/AddonManager.tsx | 79 +++++++++----------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 5d9f7887..8a564381 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, } interface Addon { @@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const tripAddons = addons.filter(a => a.type === 'trip') - const globalAddons = addons.filter(a => a.type === 'global') const photoProviderAddons = addons.filter(isPhotoProviderAddon) + const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon) + const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) + const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') - const photosAddon = tripAddons.find(isPhotosAddon) const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ key: provider.id, label: provider.name, @@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {tripAddons.map(addon => (
- - {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( -
-
- {providerOptions.map(provider => ( -
-
-
{provider.label}
-
{provider.description}
-
-
- - {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ))} -
-
- )} + {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{globalAddons.map(addon => ( - +
+ + {/* Memories providers as sub-items under Journey addon */} + {addon.id === 'journey' && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} +
))}
)} From 81d3d6cc7d5fb3bfb3baaf34839425cc92e5780f Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:30:16 +0200 Subject: [PATCH 06/10] Fix local photos showing wrong provider label in gallery (#625) - Guard provider badge with truthy check to handle null/undefined provider - Use explicit provider name matching instead of binary immich/synology fallback --- client/src/pages/JourneyDetailPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index d8875d19..bc780a36 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -928,11 +928,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres > - {photo.provider !== 'local' && ( + {photo.provider && photo.provider !== 'local' && (
- {photo.provider === 'immich' ? 'Immich' : 'Synology'} + {photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
)} From 5ea4095bebacd7716433fb82ce7f6cc92ee918cf Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:31:47 +0200 Subject: [PATCH 07/10] Fix content divider placed above paragraph instead of below (#624) - Change divider from line-prefix action to insert action at cursor position - Divider now inserts after the cursor with proper spacing --- client/src/components/Journey/MarkdownToolbar.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/components/Journey/MarkdownToolbar.tsx b/client/src/components/Journey/MarkdownToolbar.tsx index 6a82cadb..4ad519eb 100644 --- a/client/src/components/Journey/MarkdownToolbar.tsx +++ b/client/src/components/Journey/MarkdownToolbar.tsx @@ -6,7 +6,7 @@ interface Props { dark?: boolean } -type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } +type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string } const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [ { icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } }, @@ -16,7 +16,7 @@ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> { icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } }, { icon: List, label: 'List', action: { type: 'line', prefix: '- ' } }, { icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } }, - { icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } }, + { icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } }, ] export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) { @@ -35,6 +35,9 @@ export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) if (action.type === 'wrap') { result = text.slice(0, start) + action.before + selected + action.after + text.slice(end) cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length + } else if (action.type === 'insert') { + result = text.slice(0, start) + action.text + text.slice(end) + cursorPos = start + action.text.length } else { // line prefix — find start of current line const lineStart = text.lastIndexOf('\n', start - 1) + 1 From 563b338ee38c07d4415d92969441d01b65e98475 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:35:12 +0200 Subject: [PATCH 08/10] Fix journey settings dialog not scrollable on mobile (#626) - Prevent background scroll-through with overscroll-contain and touch event handling - Use bottom-sheet style on mobile (rounded-t, items-end) for better reachability - Add extra bottom padding for mobile navbar safe area - Close dialog when tapping overlay background --- client/src/pages/JourneyDetailPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index bc780a36..87319525 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -2733,8 +2733,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: { } return ( -
-
+
{ if (e.target === e.currentTarget) e.preventDefault() }}> +
e.stopPropagation()}>

{t('journey.settings.title')}

@@ -2743,7 +2743,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
-
+
{/* Cover Image */}
@@ -2846,7 +2846,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
{/* Footer */} -
+
)}
From fc6430d5ad0f93e22da28415ae0f967fe03ac1f4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 14 Apr 2026 20:47:30 +0200 Subject: [PATCH 10/10] Fix AddonManager test for provider sub-toggles under Journey addon - Add journey addon to mock data so providers render under it - Update toggle count assertion (journey + 2 providers = 3) --- client/src/components/Admin/AddonManager.test.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 51054bef..206f063d 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -190,11 +190,12 @@ describe('AddonManager', () => { expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); }); - it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => { + it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => { server.use( http.get('/api/admin/addons', () => HttpResponse.json({ addons: [ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }), buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }), buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }), @@ -204,18 +205,16 @@ describe('AddonManager', () => { ); render(); - // Provider sub-rows are visible + // Provider sub-rows are visible under Journey addon await screen.findByText('Unsplash'); expect(screen.getByText('Pexels')).toBeInTheDocument(); - // Memories row shows name override - expect(screen.getByText('Memories providers')).toBeInTheDocument(); + // Journey addon is rendered + expect(screen.getByText('Journey')).toBeInTheDocument(); - // The photos addon row itself has no top-level toggle (hideToggle = true) - // The toggle buttons are only for the providers + // Toggle buttons: journey toggle + 2 provider toggles const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); - // Should be 2 provider toggles (no main toggle for the photos addon) - expect(toggleBtns.length).toBe(2); + expect(toggleBtns.length).toBe(3); }); it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {