Merge pull request #820 from mauriceboe/fix/802-819-journey-gallery-mobile-fixes

fix(journey): dedupe gallery photos and fix Immich picker button visibility on mobile (#802 #819)
This commit is contained in:
Julien G.
2026-04-21 23:32:24 +02:00
committed by GitHub
2 changed files with 39 additions and 25 deletions
+37 -23
View File
@@ -1010,9 +1010,16 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
}, []) }, [])
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = [] const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
const seenPhotoIds = new Map<number, number>() // photo_id → index in allPhotos
for (const e of entries) { for (const e of entries) {
for (const p of e.photos) { 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) => { const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState() const store = useJourneyStore.getState()
if (store.current) { if (!store.current) return
const updated = { const target = store.current.entries.flatMap(e => e.photos).find(p => p.id === photoId)
...store.current, if (!target) return
entries: store.current.entries.map(e => ({ const siblingIds = store.current.entries.flatMap(e => e.photos).filter(p => p.photo_id === target.photo_id).map(p => p.id)
...e,
photos: e.photos.filter(p => p.id !== photoId), // Optimistic update — remove every row with this photo_id
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story), const updated = {
} ...store.current,
useJourneyStore.setState({ current: updated }) 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 { try {
await journeyApi.deletePhoto(photoId) await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id)))
} catch { } catch {
toast.error(t('common.error')) 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') : t('journey.picker.newGallery')
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white"> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'} {provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2> </h2>
@@ -1807,7 +1818,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Filter bar */} {/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700"> <div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{[ {[
@@ -1893,7 +1904,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Add-to entry selector */} {/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span> <span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button <button
@@ -1946,7 +1957,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null if (selectable.length === 0) return null
return ( return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900"> <div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
<button <button
onClick={() => { onClick={() => {
if (allSelected) { if (allSelected) {
@@ -1971,7 +1982,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
})()} })()}
{/* Photo grid */} {/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
@@ -2044,7 +2055,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span> <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span> <span className="leading-[18px]">{t('journey.picker.selected')}</span>
@@ -2243,6 +2254,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const uniqueGalleryPhotos = Array.from(new Map(galleryPhotos.map(gp => [gp.photo_id, gp])).values())
const availableGalleryPhotos = uniqueGalleryPhotos.filter(gp => !photos.some(p => p.photo_id === gp.photo_id))
const handleClose = () => { const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose() onClose()
@@ -2352,7 +2366,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
{showGalleryPick && ( {showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50"> <div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto"> <div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => ( {availableGalleryPhotos.map(gp => (
<div <div
key={gp.id} key={gp.id}
onClick={async () => { onClick={async () => {
@@ -2372,7 +2386,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> <img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div> </div>
))} ))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( {availableGalleryPhotos.length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div> <div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)} )}
</div> </div>
+2 -2
View File
@@ -58,7 +58,7 @@ export function listJourneys(userId: number) {
return db.prepare(` return db.prepare(`
SELECT DISTINCT j.*, SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count, (SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count, (SELECT COUNT(DISTINCT jp.photo_id) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count, (SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min, (SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max (SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
@@ -160,7 +160,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
// stats // stats
const entryCount = entries.filter(e => e.type === 'entry').length; const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length; const photoCount = new Set(photos.map(p => p.photo_id)).size;
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare( const userPrefs = db.prepare(