feat: bulk tag add/remove on multi-select right sidebar

The bulk action panel previously covered rating, color, flag, and pick
but had no way to apply tags across a multi-photo selection — the only
path was to tag photos one at a time via the single-photo PhotoInfoPanel.
Add it.

- backend: extend the existing /photos/bulk action endpoint with
  add_tags and remove_tags actions. add_tags is idempotent (computes
  the new (photo_id, tag_id) pair set against existing rows and inserts
  only the missing ones); remove_tags is a single DELETE WHERE IN.
- api.ts: bulkAddTags / bulkRemoveTags wrappers.
- RightSidebar: new BulkTagsEditor below the bulk flag row. Filters /
  searches the existing tag list, lets the user click any chip to apply
  it to the whole selection or X to remove it. Typing a name with no
  exact match shows a "Create and apply" button that creates the tag
  via tagsApi.create and immediately attaches it to every selected
  photo. All three mutations invalidate both the photo and tag caches
  so the FilterBar tag count + sidebar Tags section stay fresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:25:18 +02:00
parent a55839d9a2
commit a5b4054a71
3 changed files with 278 additions and 2 deletions

View File

@@ -116,6 +116,28 @@ export const photos = {
return response.data
},
/** Add the listed tags to every listed photo. Idempotent — re-adding
* an existing (photo, tag) pair is a no-op. Returns { added: N }. */
bulkAddTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'add_tags',
value: tagIds,
})
return response.data as { status: string; added: number }
},
/** Remove the listed tags from every listed photo. Removing a
* non-member is a no-op. Returns { removed: N }. */
bulkRemoveTags: async (photoIds: string[], tagIds: string[]) => {
const response = await api.post('/photos/bulk', {
ids: photoIds,
action: 'remove_tags',
value: tagIds,
})
return response.data as { status: string; removed: number }
},
/** Move photos into a target folder (or source root). Returns
* { moved, errors[] }. */
move: async (photoIds: string[], targetId: string) => {