fix(journey): thumbnails, batch add, optimistic delete, shared albums

- 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
This commit is contained in:
Maurice
2026-04-13 22:48:40 +02:00
parent 88e1d075e0
commit 240b10a192
4 changed files with 68 additions and 26 deletions
+1
View File
@@ -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<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
+33 -20
View File
@@ -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))}
>
<img
src={photoUrl(photo, 'original')}
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
@@ -927,12 +939,10 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return }
}
let added = 0
for (const assetId of assetIds) {
try {
await journeyApi.addProviderPhoto(targetId, pickerProvider!, assetId)
added++
} catch {}
}
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
added = result.added || 0
} catch {}
if (added > 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 (
<img
src={src}
@@ -1631,14 +1641,13 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
</div>
{/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4">
{/* 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 (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button
onClick={() => {
if (allSelected) {
@@ -1647,7 +1656,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
setSelected(new Set(selectable.map((a: any) => a.id)))
}
}}
className="mb-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
allSelected
@@ -1658,8 +1667,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
)
})()}
</div>
)
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<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" />