From 71aa8f8051d8cd3ef9d870a149f09ab35ef1003f Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 22 Apr 2026 15:58:31 +0200 Subject: [PATCH] feat: journey gallery 1-to-N model with M:N entry-photo junction table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old model where journey_photos was keyed per-entry with a per-journey gallery table (one row per unique photo per journey) and a new junction table journey_entry_photos that links gallery photos to entries. Key changes: - Migration 121: renames old journey_photos to journey_photos_old, creates the new gallery table + junction table, backfills both from existing data, drops the backup, removes synthetic 'Gallery' / '[Trip Photos]' wrapper entries - journeyService: rewrites photo helpers (JP_SELECT/JOIN now joins via journey_entry_photos → journey_photos → trek_photos); adds uploadGalleryPhotos, addProviderPhotoToGallery, unlinkPhotoFromEntry, deleteGalleryPhoto; simplifies deletePhoto and linkPhotoToEntry against the new schema; syncTripPhotos inserts directly into the gallery instead of a wrapper entry - journeyShareService: updates public photo and asset validation queries to join through the gallery table instead of entry_id; getPublicJourney now returns a dedicated gallery array alongside per-entry photos - journey routes: adds gallery upload, provider-photo, and delete endpoints (POST/DELETE /:id/gallery/*); adds unlink-from-entry route (DELETE /entries/:entryId/photos/:journeyPhotoId); updates link-photo to accept journey_photo_id with a backwards-compat photo_id alias - types: adds GalleryPhoto interface - client api: adds uploadGalleryPhotos, addProviderPhotosToGallery, unlinkPhoto, deleteGalleryPhoto; updates linkPhoto param name to journeyPhotoId - journeyStore: adds GalleryPhoto type, gallery field on JourneyDetail, uploadGalleryPhotos / unlinkPhoto / deleteGalleryPhoto store actions - JourneyDetailPage + tests: updated to work with the new gallery model --- client/src/api/client.ts | 6 +- client/src/pages/JourneyDetailPage.test.tsx | 62 ++-- client/src/pages/JourneyDetailPage.tsx | 119 +++----- client/src/store/journeyStore.ts | 65 ++++ server/src/db/migrations.ts | 97 ++++++ server/src/routes/journey.ts | 89 ++++-- server/src/services/journeyService.ts | 288 +++++++++--------- server/src/services/journeyShareService.ts | 45 +-- .../src/services/memories/helpersService.ts | 18 +- server/src/types.ts | 18 ++ 10 files changed, 510 insertions(+), 297 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 1322b8bb..378ddeab 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -356,9 +356,13 @@ export const journeyApi = { // Photos uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), - linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), + linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), + unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data), + deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data), updatePhoto: (photoId: number, data: Record) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 3abdc2e3..abdd1443 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -177,6 +177,24 @@ const mockJourneyDetail = { }, ], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [ + { + id: 100, + journey_id: 1, + photo_id: 100, + provider: 'local', + file_path: 'photos/test.jpg', + asset_id: null, + owner_id: null, + thumbnail_path: null, + caption: 'Colosseum', + sort_order: 0, + width: 800, + height: 600, + shared: 1, + created_at: now, + }, + ], }; // ── MSW Handlers ───────────────────────────────────────────────────────────── @@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => { it('renders the empty gallery state when journey has no photos', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - // Override with entries that have no photos + // Override with entries that have no photos and empty gallery const emptyEntry = { ...mockJourneyDetail.entries[0], photos: [], }; setupDefaultHandlers({ entries: [emptyEntry], + gallery: [], stats: { entries: 1, photos: 0, places: 1 }, }); @@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => { expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); }); - // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo - // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US - const dateOverlay = document.querySelector('[class*="opacity-0"]'); - expect(dateOverlay).toBeTruthy(); + // Gallery photos render in a grid; each photo has a group container + const photos = document.querySelectorAll('[class*="aspect-square"]'); + expect(photos.length).toBeGreaterThanOrEqual(1); }); }); @@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => { setupDefaultHandlers({ entries: [immichEntry, mockJourneyDetail.entries[1]], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [{ + id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null, + asset_id: 'asset-123', owner_id: 1, thumbnail_path: null, + caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, + }], }); render(); @@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => { setupDefaultHandlers({ entries: [synologyEntry, mockJourneyDetail.entries[1]], stats: { entries: 2, photos: 1, places: 2 }, + gallery: [{ + id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null, + asset_id: 'syn-456', owner_id: 1, thumbnail_path: null, + caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, + }], }); render(); @@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => { // ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { - it('uploading files in gallery creates an entry and uploads photos', async () => { + it('uploading files in gallery calls gallery upload API', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - let createCalled = false; let uploadCalled = false; server.use( - http.post('/api/journeys/1/entries', () => { - createCalled = true; - return HttpResponse.json({ - id: 99, journey_id: 1, author_id: 1, type: 'entry', - entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null, - location_lat: null, location_lng: null, mood: null, weather: null, - tags: [], pros_cons: null, visibility: 'private', sort_order: 0, - entry_time: null, photos: [], created_at: now, updated_at: now, - }); - }), - http.post('/api/journeys/entries/99/photos', () => { + http.post('/api/journeys/1/gallery/photos', () => { uploadCalled = true; - return HttpResponse.json([]); + return HttpResponse.json({ photos: [] }); }), ); @@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => { const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); await user.upload(fileInput, testFile); - await waitFor(() => { - expect(createCalled).toBe(true); - }); await waitFor(() => { expect(uploadCalled).toBe(true); }); @@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => { let deleteCalled = false; server.use( - http.delete('/api/journeys/photos/100', () => { + http.delete('/api/journeys/1/gallery/100', () => { deleteCalled = true; - return HttpResponse.json({ success: true }); + return new HttpResponse(null, { status: 204 }); }), ); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 4c2c3643..e7aa51f9 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -27,7 +27,7 @@ import { import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileEntryView from '../components/Journey/MobileEntryView' import { useIsMobile } from '../hooks/useIsMobile' -import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' +import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import { computeJourneyLifecycle } from '../utils/journeyLifecycle' const GRADIENTS = [ @@ -80,7 +80,7 @@ function formatDate(d: string, locale?: string): { weekday: string; month: strin } } -function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { +function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string { return `/api/photos/${p.photo_id}/${size}` } @@ -341,7 +341,7 @@ export default function JourneyDetailPage() { ) } - const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton')) + const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton')) const dayGroups = groupByDate(timelineEntries) const sortedDates = [...dayGroups.keys()].sort() @@ -458,7 +458,7 @@ export default function JourneyDetailPage() { className={ isMobile ? '' - : 'flex-1 overflow-y-auto journey-feed-scroll' + : 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll' } >
@@ -693,10 +693,11 @@ export default function JourneyDetailPage() { > setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} + onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onRefresh={() => loadJourney(Number(id))} />
@@ -733,7 +734,7 @@ export default function JourneyDetailPage() { entry={editingEntry} journeyId={current.id} tripDates={tripDates} - galleryPhotos={current.entries.flatMap(e => e.photos || [])} + galleryPhotos={current.gallery || []} onClose={() => setEditingEntry(null)} onSave={async (data) => { let entryId = editingEntry.id @@ -971,12 +972,13 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe // ── Gallery View ────────────────────────────────────────────────────────── -function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: { +function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: { entries: JourneyEntry[] + gallery: GalleryPhoto[] journeyId: number userId: number trips: JourneyTrip[] - onPhotoClick: (photos: JourneyPhoto[], index: number) => void + onPhotoClick: (photos: GalleryPhoto[], index: number) => void onRefresh: () => void }) { const { t } = useTranslation() @@ -1009,19 +1011,7 @@ 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) { - 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 } - } - } - } + const allPhotos = gallery const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) @@ -1037,22 +1027,9 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres if (!files?.length) return setGalleryUploading(true) try { - // find existing "Gallery" entry or create one. The stored title is the - // literal 'Gallery' (server-side checks look for this exact string) — - // do not send a translated label here. - let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') - let entryId = galleryEntry?.id - if (!entryId) { - const entry = await journeyApi.createEntry(journeyId, { - title: 'Gallery', - entry_date: new Date().toISOString().split('T')[0], - type: 'entry', - }) - entryId = entry.id - } const formData = new FormData() for (const f of files) formData.append('photos', f) - await journeyApi.uploadPhotos(entryId, formData) + await journeyApi.uploadGalleryPhotos(journeyId, formData) toast.success(t('journey.photosUploaded', { count: files.length })) onRefresh() } catch { @@ -1063,25 +1040,24 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres e.target.value = '' } - const handleDeletePhoto = async (photoId: number) => { + const handleDeletePhoto = async (galleryPhotoId: number) => { const store = useJourneyStore.getState() 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 }) + // Optimistic update — remove from gallery and all entry photo lists + useJourneyStore.setState({ + current: { + ...store.current, + gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId), + entries: store.current.entries.map(e => ({ + ...e, + photos: e.photos.filter(p => p.id !== galleryPhotoId), + })), + }, + }) try { - await Promise.all(siblingIds.map(id => journeyApi.deletePhoto(id))) + await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId) } catch { toast.error(t('common.error')) onRefresh() @@ -1132,11 +1108,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres ) : (
- {allPhotos.map(({ photo, entry }, i) => ( + {allPhotos.map((photo, i) => (
onPhotoClick(allPhotos.map(a => a.photo), i)} + onClick={() => onPhotoClick(allPhotos, i)} > {photo.caption}

)} -
- - {new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })} - -
))} @@ -1182,25 +1153,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres userId={userId} entries={entriesWithContent} trips={trips} - existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))} + existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))} onClose={() => setShowPicker(false)} onAdd={async (groups, entryId) => { - let targetId = entryId - if (!targetId) { - try { - const entry = await journeyApi.createEntry(journeyId, { - title: 'Gallery', - entry_date: new Date().toISOString().split('T')[0], - type: 'entry', - }) - targetId = entry.id - } catch { return } - } let added = 0 for (const group of groups) { try { - const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase) - added += result.added || 0 + if (entryId) { + const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase) + added += result.added || 0 + } else { + const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase) + added += result.added || 0 + } } catch {} } if (added > 0) { @@ -2201,7 +2166,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa entry: JourneyEntry journeyId: number tripDates: Set - galleryPhotos: JourneyPhoto[] + galleryPhotos: GalleryPhoto[] onClose: () => void onSave: (data: Record) => Promise onUploadPhotos: (entryId: number, formData: FormData) => Promise @@ -2227,7 +2192,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa const [cons, setCons] = useState(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [saving, setSaving] = useState(false) const [uploading, setUploading] = useState(false) - const [photos, setPhotos] = useState(entry.photos || []) + const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [pendingFiles, setPendingFiles] = useState([]) const [pendingLinkIds, setPendingLinkIds] = useState([]) const [showGalleryPick, setShowGalleryPick] = useState(false) @@ -2254,8 +2219,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa 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 availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)) const handleClose = () => { if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return @@ -2421,8 +2385,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa