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:
@@ -862,6 +862,54 @@ async def bulk_action(
|
||||
elif action.action == 'set_color':
|
||||
for photo in photos:
|
||||
photo.color_label = action.value
|
||||
elif action.action == 'add_tags':
|
||||
# value is a list of tag ids. We bulk-insert (photo_id, tag_id)
|
||||
# rows for every (photo, tag) combination that doesn't already
|
||||
# exist, so the operation is idempotent.
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "added": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
existing = await db.execute(
|
||||
select(photo_tags.c.photo_id, photo_tags.c.tag_id).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {(row[0], row[1]) for row in existing.all()}
|
||||
new_rows = [
|
||||
{"photo_id": pid, "tag_id": tid}
|
||||
for pid in photo_ids
|
||||
for tid in tag_ids
|
||||
if (pid, tid) not in existing_pairs
|
||||
]
|
||||
if new_rows:
|
||||
from sqlalchemy import insert
|
||||
await db.execute(insert(photo_tags), new_rows)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"added": len(new_rows),
|
||||
"message": f"Added {len(new_rows)} tag link{'s' if len(new_rows) != 1 else ''}",
|
||||
}
|
||||
elif action.action == 'remove_tags':
|
||||
tag_ids = action.value or []
|
||||
if not isinstance(tag_ids, list) or not tag_ids:
|
||||
return {"status": "success", "removed": 0, "message": "No tags supplied"}
|
||||
photo_ids = [p.id for p in photos]
|
||||
from sqlalchemy import delete as sql_delete
|
||||
result = await db.execute(
|
||||
sql_delete(photo_tags).where(
|
||||
photo_tags.c.photo_id.in_(photo_ids),
|
||||
photo_tags.c.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "success",
|
||||
"removed": result.rowcount or 0,
|
||||
"message": f"Removed tag link{'s' if (result.rowcount or 0) != 1 else ''}",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Invalid action")
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { X, Star, Info, ShoppingBasket, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||
import {
|
||||
photos as photosApi,
|
||||
heaps as heapsApi,
|
||||
tags as tagsApi,
|
||||
} from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
@@ -39,6 +45,59 @@ export function RightSidebar() {
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
|
||||
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||
// query so the FilterBar / sidebar tag counts stay fresh.
|
||||
const invalidateTagsAndPhotos = () => {
|
||||
invalidatePhotoQueries()
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
}
|
||||
const bulkAddTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkAddTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const added = data?.added ?? 0
|
||||
toast.success(
|
||||
'Tags added',
|
||||
`${added} new link${added === 1 ? '' : 's'}`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
const bulkRemoveTagsMutation = useMutation({
|
||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||
photosApi.bulkRemoveTags(ids, tagIds),
|
||||
onSuccess: (data) => {
|
||||
const removed = data?.removed ?? 0
|
||||
toast.success(
|
||||
'Tags removed',
|
||||
`${removed} link${removed === 1 ? '' : 's'} removed`
|
||||
)
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Remove tags failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Idempotent create-and-attach: lets the user type a brand-new tag
|
||||
// name and apply it to the whole selection in one click.
|
||||
const createAndAttachMutation = useMutation({
|
||||
mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => {
|
||||
const created = await tagsApi.create(name)
|
||||
return photosApi.bulkAddTags(ids, [created.id])
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Tag created and applied')
|
||||
invalidateTagsAndPhotos()
|
||||
},
|
||||
onError: (e: any) =>
|
||||
toast.error('Create tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// Active heap membership for the bulk Pick toggle.
|
||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
@@ -223,7 +282,154 @@ export function RightSidebar() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk tags. Click an existing tag chip to apply it to the
|
||||
* whole selection; long-press / X icon to remove. The text
|
||||
* input adds an existing tag if it matches a name, or creates
|
||||
* a new tag and applies it. */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-text-muted">Tags</label>
|
||||
<BulkTagsEditor
|
||||
allTags={allTags}
|
||||
tagInput={tagInput}
|
||||
onTagInputChange={setTagInput}
|
||||
disabled={
|
||||
bulkAddTagsMutation.isPending ||
|
||||
bulkRemoveTagsMutation.isPending ||
|
||||
createAndAttachMutation.isPending
|
||||
}
|
||||
onApply={(tagId) =>
|
||||
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
|
||||
}
|
||||
onRemove={(tagId) =>
|
||||
bulkRemoveTagsMutation.mutate({
|
||||
ids: selectedPhotos,
|
||||
tagIds: [tagId],
|
||||
})
|
||||
}
|
||||
onCreate={(name) => {
|
||||
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
|
||||
setTagInput('')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BulkTagsEditorProps {
|
||||
allTags: { id: string; name: string; color: string | null }[]
|
||||
tagInput: string
|
||||
onTagInputChange: (value: string) => void
|
||||
disabled: boolean
|
||||
onApply: (tagId: string) => void
|
||||
onRemove: (tagId: string) => void
|
||||
onCreate: (name: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
|
||||
* single-photo TagsEditor we don't show "current tags" — there's no clean
|
||||
* single-photo notion of that across an arbitrary selection. Instead the
|
||||
* user picks an existing tag (apply to all) or types a new one (create
|
||||
* and apply to all).
|
||||
*/
|
||||
function BulkTagsEditor({
|
||||
allTags,
|
||||
tagInput,
|
||||
onTagInputChange,
|
||||
disabled,
|
||||
onApply,
|
||||
onRemove,
|
||||
onCreate,
|
||||
}: BulkTagsEditorProps) {
|
||||
const trimmed = tagInput.trim()
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
const filtered = trimmed
|
||||
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
|
||||
: allTags
|
||||
|
||||
const exactMatch = trimmed
|
||||
? allTags.find((t) => t.name.toLowerCase() === lower)
|
||||
: null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmed || disabled) return
|
||||
if (exactMatch) {
|
||||
onApply(exactMatch.id)
|
||||
onTagInputChange('')
|
||||
} else {
|
||||
onCreate(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onTagInputChange('')
|
||||
}
|
||||
}}
|
||||
placeholder="Filter or create…"
|
||||
disabled={disabled}
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{trimmed && !exactMatch && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Create "{trimmed}" and apply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
|
||||
{filtered.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
style={
|
||||
tag.color
|
||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onApply(tag.id)}
|
||||
disabled={disabled}
|
||||
className="hover:underline disabled:opacity-50"
|
||||
title={`Apply "${tag.name}" to selection`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemove(tag.id)}
|
||||
disabled={disabled}
|
||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
|
||||
title={`Remove "${tag.name}" from selection`}
|
||||
aria-label={`Remove ${tag.name} from selection`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-faint">No tags match</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user