From 3f489880daa91fae8386d3697c6e8cbed0e187a7 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 21 Apr 2026 23:26:02 +0200 Subject: [PATCH] fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819) Fix #802: ProviderPicker modal now uses dvh-based max-height, items-end on mobile (bottom-sheet), flex-shrink-0 on all fixed sections, min-h-0 on the scrollable grid, and env(safe-area-inset-bottom) padding so the Add button is always reachable above the iOS home indicator. Fix #819: Gallery view now deduplicates photos by photo_id (underlying trek_photos.id) so a photo linked from Gallery into an activity no longer appears twice. Gallery delete cascades to all copies. EntryEditor From Gallery grid and photo count also deduplicated. Server photo_count uses COUNT(DISTINCT photo_id). Preserves #729 guarantee (removing from an activity does not delete the Gallery copy). --- client/src/pages/JourneyDetailPage.tsx | 60 ++++++++++++++++---------- server/src/services/journeyService.ts | 4 +- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index f8e3db24..4c2c3643 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -1010,9 +1010,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres }, []) const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] + const seenPhotoIds = new Map() // photo_id → index in allPhotos for (const e of entries) { for (const p of e.photos) { - allPhotos.push({ photo: p, entry: e }) + const existing = seenPhotoIds.get(p.photo_id) + if (existing === undefined) { + seenPhotoIds.set(p.photo_id, allPhotos.length) + allPhotos.push({ photo: p, entry: e }) + } else if (e.title === 'Gallery' && allPhotos[existing].entry.title !== 'Gallery') { + allPhotos[existing] = { photo: p, entry: e } + } } } @@ -1057,23 +1064,27 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres } const handleDeletePhoto = async (photoId: number) => { - // Optimistic update — remove photo from local state immediately const store = useJourneyStore.getState() - if (store.current) { - const updated = { - ...store.current, - entries: store.current.entries.map(e => ({ - ...e, - photos: e.photos.filter(p => p.id !== photoId), - })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), - } - useJourneyStore.setState({ current: updated }) + if (!store.current) return + const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId) + if (!target) return + const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id) + + // Optimistic update — remove every row with this photo_id + const updated = { + ...store.current, + entries: store.current.entries.map(e => ({ + ...e, + photos: e.photos.filter(p => p.photo_id !== target.photo_id), + })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), } + useJourneyStore.setState({ current: updated }) + try { - await journeyApi.deletePhoto(photoId) + await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) } catch { toast.error(t('common.error')) - onRefresh() // Revert on error + onRefresh() } } @@ -1793,11 +1804,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on : t('journey.picker.newGallery') return ( -
-
+
{ if (e.target === e.currentTarget) e.preventDefault() }}> +
e.stopPropagation()}> {/* Header */} -
+

{provider === 'immich' ? 'Immich' : 'Synology Photos'}

@@ -1807,7 +1818,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Filter bar */} -
+
{/* Tabs */}
{[ @@ -1893,7 +1904,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Add-to entry selector */} -
+
{t('journey.picker.addTo')}