fix(metadata-panel): apply mutation responses synchronously, no second GET
Single-photo updateMutation only invalidated, so the panel waited for
a follow-up GET /photos/{id} round-trip before showing the new value —
felt as a 200–500 ms lag after every taken_at / rating / notes edit.
Use the PATCH response (already the updated row) to merge into the
per-photo cache and patch every cached timeline list in place.
Bulk taken_at had the same shape: invalidate-only, no optimistic. When
the user dropped back from N selected to one of the modified photos
the panel briefly showed the pre-edit value. Move both bulkSetTakenAt
and bulkSetTakenAtMap into useBulkPhotoMutations alongside the rating/
color/notes pattern, with the same snapshot+patch+rollback primitives.
Tags + bulk tags still invalidate-only — separate change if needed.
This commit is contained in:
@@ -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<string, string>) =>
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<PhotoDetails> & { id: string }) => {
|
||||
queryClient.setQueryData<PhotoDetails>(['photo', photoId], (prev) =>
|
||||
prev ? { ...prev, ...updated } : (updated as PhotoDetails),
|
||||
)
|
||||
queryClient.setQueriesData<PhotoDetails[]>(
|
||||
{ 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 })
|
||||
},
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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<Photo[]>({ 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>(['photo', id])
|
||||
if (cur) {
|
||||
queryClient.setQueryData<Photo>(['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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user