From 240b10a19224f9e81cf38e0c65eaa07715d470e9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 13 Apr 2026 22:48:40 +0200 Subject: [PATCH] fix(journey): thumbnails, batch add, optimistic delete, shared albums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gallery/timeline load thumbnails instead of originals (50-100KB vs 2-5MB) - Batch endpoint for adding multiple provider photos in one request - Optimistic photo deletion — no full page reload on delete - Immich albums include shared albums - Select-all button moved outside scroll container (always visible) - Album tab loads actual album contents via /albums/:id/photos --- client/src/api/client.ts | 1 + client/src/pages/JourneyDetailPage.tsx | 53 ++++++++++++------- server/src/routes/journey.ts | 14 ++++- server/src/services/memories/immichService.ts | 26 +++++++-- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 156da726..791c2a5e 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -309,6 +309,7 @@ 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), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data), + addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data), linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).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.tsx b/client/src/pages/JourneyDetailPage.tsx index 935d640f..ad832d94 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -814,11 +814,23 @@ 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 }) + } try { await journeyApi.deletePhoto(photoId) - onRefresh() } catch { toast.error(t('common.error')) + onRefresh() // Revert on error } } @@ -869,7 +881,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))} > {photo.caption 0) { toast.success(t('journey.photosAdded', { count: added })) onRefresh() @@ -1268,7 +1278,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v } function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) { - const src = photoUrl(photo, 'original') + const src = photoUrl(photo, 'thumbnail') return ( - {/* Photo grid */} -
- {/* Select all toggle */} - {!loading && photos.length > 0 && (() => { - const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id)) - const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) - if (selectable.length === 0) return null - return ( + {/* Select all bar — sticky above grid */} + {!loading && photos.length > 0 && (() => { + const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id)) + const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) + if (selectable.length === 0) return null + return ( +
- ) - })()} +
+ ) + })()} + + {/* Photo grid */} +
{loading ? (
diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts index 7db7a9a9..9b59240d 100644 --- a/server/src/routes/journey.ts +++ b/server/src/routes/journey.ts @@ -115,7 +115,19 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10) router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { provider, asset_id, caption } = req.body || {}; + const { provider, asset_id, asset_ids, caption } = req.body || {}; + + // Batch mode: { provider, asset_ids: string[] } + if (Array.isArray(asset_ids) && provider) { + const added: any[] = []; + for (const id of asset_ids) { + const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption); + if (photo) added.push(photo); + } + return res.status(201).json({ photos: added, added: added.length }); + } + + // Single mode (backward compat) if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' }); const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption); if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' }); diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index d4aff87a..77c8c26d 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -258,18 +258,34 @@ export async function listAlbums( if (!creds) return { error: 'Immich not configured', status: 400 }; try { - const resp = await safeFetch(`${creds.immich_url}/api/albums`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000) as any, + // Fetch both owned and shared albums + const [ownResp, sharedResp] = await Promise.all([ + safeFetch(`${creds.immich_url}/api/albums`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }), + safeFetch(`${creds.immich_url}/api/albums?shared=true`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000) as any, + }), + ]); + if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status }; + const ownAlbums = await ownResp.json() as any[]; + const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : []; + const seenIds = new Set(); + const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => { + if (seenIds.has(a.id)) return false; + seenIds.add(a.id); + return true; }); - if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status }; - const albums = (await resp.json() as any[]).map((a: any) => ({ + const albums = allAlbums.map((a: any) => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount || 0, startDate: a.startDate, endDate: a.endDate, albumThumbnailAssetId: a.albumThumbnailAssetId, + shared: a.shared || a.sharedUsers?.length > 0, })); return { albums }; } catch {