diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index c92e8d6..4725da7 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -38,6 +38,8 @@ export function RightSidebar() { bulkRating: bulkRatingMutation, bulkColor: bulkColorMutation, bulkNotes: bulkNotesMutation, + bulkTakenAt: bulkTakenAtMutation, + bulkTakenAtMap: bulkTakenAtMapMutation, invalidatePhotoQueries, } = useBulkPhotoMutations() @@ -53,10 +55,11 @@ export function RightSidebar() { onSuccess: invalidatePhotoQueries, }) - // Shared report-and-invalidate tail for both bulk taken_at mutations. - // They return a partial-apply shape (updated/skipped/errors) because - // EXIF writes can fail per-photo (unsupported format, missing file) - // without wrecking the rest of the batch. + // Toast for bulk taken_at responses. The endpoints return a partial- + // apply shape (updated/skipped/errors) because EXIF writes can fail + // per-photo (unsupported format, missing file) without wrecking the + // rest of the batch. Cache reconciliation is handled inside + // useBulkPhotoMutations; this just surfaces the count to the user. const reportBulkTakenAt = ( data: { status: string @@ -75,25 +78,8 @@ export function RightSidebar() { } else { toast.success('Dates updated', detail) } - invalidatePhotoQueries() } - const bulkTakenAtMutation = useMutation({ - mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) => - photosApi.bulkSetTakenAt(ids, iso), - onSuccess: reportBulkTakenAt, - onError: (e: any) => - toast.error('Date update failed', formatApiError(e)), - }) - - const bulkTakenAtMapMutation = useMutation({ - mutationFn: (map: Record) => - photosApi.bulkSetTakenAtMap(map), - onSuccess: reportBulkTakenAt, - onError: (e: any) => - toast.error('Date update failed', formatApiError(e)), - }) - // Bulk tag mutations. Tag mutations also need to invalidate the tags // query so the FilterBar / sidebar tag counts stay fresh. const invalidateTagsAndPhotos = () => { @@ -486,9 +472,16 @@ export function RightSidebar() { selectedCount={selectedPhotos.length} collectPhotos={collectSelectedPhotos} onApplyUniform={(iso) => - bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso }) + bulkTakenAtMutation.mutate( + { ids: selectedPhotos, iso }, + { onSuccess: reportBulkTakenAt }, + ) + } + onApplyMap={(map) => + bulkTakenAtMapMutation.mutate(map, { + onSuccess: reportBulkTakenAt, + }) } - onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)} /> diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index d84a67b..a6d0262 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -164,8 +164,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro placeholderData: keepPreviousData, }) - // Mutation for any patchable field. Invalidates both the photo detail - // cache and the timeline list so the grid reflects the change too. + // Mutation for any patchable field. The PATCH response is the updated + // row, which we merge straight into the per-photo cache so the panel + // paints synchronously — no follow-up GET round-trip before the new + // value appears. Merge (not replace) because the GET endpoint adds a + // `tags` field that PATCH doesn't return; replacing would clobber it. + // Lists and stats are still invalidated so sort order and aggregates + // reconcile with the server. const updateMutation = useMutation({ mutationFn: (data: { filename?: string @@ -176,8 +181,17 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro color_label?: string | null taken_at?: string }) => photosApi.update(photoId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['photo', photoId] }) + onSuccess: (updated: Partial & { id: string }) => { + queryClient.setQueryData(['photo', photoId], (prev) => + prev ? { ...prev, ...updated } : (updated as PhotoDetails), + ) + queryClient.setQueriesData( + { queryKey: ['photos'] }, + (prev) => + prev + ? prev.map((p) => (p.id === updated.id ? { ...p, ...updated } : p)) + : prev, + ) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) }, diff --git a/frontend/src/hooks/useBulkPhotoMutations.ts b/frontend/src/hooks/useBulkPhotoMutations.ts index 24775da..ac6cd27 100644 --- a/frontend/src/hooks/useBulkPhotoMutations.ts +++ b/frontend/src/hooks/useBulkPhotoMutations.ts @@ -189,5 +189,75 @@ export function useBulkPhotoMutations() { onSuccess: invalidatePhotoQueries, }) - return { bulkRating, bulkColor, bulkNotes, invalidatePhotoQueries } + // Bulk taken-at: same iso applied to every selected id. Mirror the + // server's manual-source flip so the badge updates without waiting on + // the reconcile fetch. has_date_warning isn't patched optimistically — + // it's recomputed server-side and the post-success invalidate carries + // it back. Per-photo errors (unsupported EXIF formats) come through + // the response shape, not as a thrown error, so onError isn't the + // place to roll back; instead we rely on the invalidate to bring back + // any rows the server skipped. + const bulkTakenAt = useMutation({ + mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) => + photosApi.bulkSetTakenAt(ids, iso), + onMutate: ({ ids, iso }) => { + const snapshot = snapshotPhotos(ids) + patchPhotos(ids, { taken_at: iso, taken_at_source: 'manual' }) + return { snapshot } + }, + onError: (e, _vars, ctx) => { + if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot) + toast.error('Date update failed', formatApiError(e)) + }, + onSuccess: invalidatePhotoQueries, + }) + + // Bulk taken-at with a per-photo iso map. Same shape as bulkTakenAt + // otherwise — patch each row to its own iso, snapshot the lot for + // rollback, reconcile via invalidate. + const bulkTakenAtMap = useMutation({ + mutationFn: (map: Record) => + photosApi.bulkSetTakenAtMap(map), + onMutate: (map) => { + const ids = Object.keys(map) + const snapshot = snapshotPhotos(ids) + const want = new Set(ids) + // Per-id patch: can't reuse patchPhotos (single-patch primitive), + // so walk the same caches inline. + queryClient.setQueriesData({ queryKey: ['photos'] }, (prev) => + prev + ? prev.map((p) => + want.has(p.id) + ? { ...p, taken_at: map[p.id], taken_at_source: 'manual' } + : p, + ) + : prev, + ) + for (const id of ids) { + const cur = queryClient.getQueryData(['photo', id]) + if (cur) { + queryClient.setQueryData(['photo', id], { + ...cur, + taken_at: map[id], + taken_at_source: 'manual', + }) + } + } + return { snapshot } + }, + onError: (e, _vars, ctx) => { + if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot) + toast.error('Date update failed', formatApiError(e)) + }, + onSuccess: invalidatePhotoQueries, + }) + + return { + bulkRating, + bulkColor, + bulkNotes, + bulkTakenAt, + bulkTakenAtMap, + invalidatePhotoQueries, + } }