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" />
+13 -1
View File
@@ -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' });
+21 -5
View File
@@ -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<string>();
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 {