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,
|
bulkRating: bulkRatingMutation,
|
||||||
bulkColor: bulkColorMutation,
|
bulkColor: bulkColorMutation,
|
||||||
bulkNotes: bulkNotesMutation,
|
bulkNotes: bulkNotesMutation,
|
||||||
|
bulkTakenAt: bulkTakenAtMutation,
|
||||||
|
bulkTakenAtMap: bulkTakenAtMapMutation,
|
||||||
invalidatePhotoQueries,
|
invalidatePhotoQueries,
|
||||||
} = useBulkPhotoMutations()
|
} = useBulkPhotoMutations()
|
||||||
|
|
||||||
@@ -53,10 +55,11 @@ export function RightSidebar() {
|
|||||||
onSuccess: invalidatePhotoQueries,
|
onSuccess: invalidatePhotoQueries,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Shared report-and-invalidate tail for both bulk taken_at mutations.
|
// Toast for bulk taken_at responses. The endpoints return a partial-
|
||||||
// They return a partial-apply shape (updated/skipped/errors) because
|
// apply shape (updated/skipped/errors) because EXIF writes can fail
|
||||||
// EXIF writes can fail per-photo (unsupported format, missing file)
|
// per-photo (unsupported format, missing file) without wrecking the
|
||||||
// without wrecking the rest of the batch.
|
// rest of the batch. Cache reconciliation is handled inside
|
||||||
|
// useBulkPhotoMutations; this just surfaces the count to the user.
|
||||||
const reportBulkTakenAt = (
|
const reportBulkTakenAt = (
|
||||||
data: {
|
data: {
|
||||||
status: string
|
status: string
|
||||||
@@ -75,25 +78,8 @@ export function RightSidebar() {
|
|||||||
} else {
|
} else {
|
||||||
toast.success('Dates updated', detail)
|
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
|
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||||
// query so the FilterBar / sidebar tag counts stay fresh.
|
// query so the FilterBar / sidebar tag counts stay fresh.
|
||||||
const invalidateTagsAndPhotos = () => {
|
const invalidateTagsAndPhotos = () => {
|
||||||
@@ -486,9 +472,16 @@ export function RightSidebar() {
|
|||||||
selectedCount={selectedPhotos.length}
|
selectedCount={selectedPhotos.length}
|
||||||
collectPhotos={collectSelectedPhotos}
|
collectPhotos={collectSelectedPhotos}
|
||||||
onApplyUniform={(iso) =>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,8 +164,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mutation for any patchable field. Invalidates both the photo detail
|
// Mutation for any patchable field. The PATCH response is the updated
|
||||||
// cache and the timeline list so the grid reflects the change too.
|
// 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({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
filename?: string
|
filename?: string
|
||||||
@@ -176,8 +181,17 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
color_label?: string | null
|
color_label?: string | null
|
||||||
taken_at?: string
|
taken_at?: string
|
||||||
}) => photosApi.update(photoId, data),
|
}) => photosApi.update(photoId, data),
|
||||||
onSuccess: () => {
|
onSuccess: (updated: Partial<PhotoDetails> & { id: string }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
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: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,5 +189,75 @@ export function useBulkPhotoMutations() {
|
|||||||
onSuccess: invalidatePhotoQueries,
|
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