From a5b4054a71b474911a5471a62ae82929d7782de2 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 20:25:18 +0200 Subject: [PATCH] feat: bulk tag add/remove on multi-select right sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/routers/photos.py | 48 ++++ .../src/components/layout/RightSidebar.tsx | 210 +++++++++++++++++- frontend/src/services/api.ts | 22 ++ 3 files changed, 278 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 24d377b..bfafe93 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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") diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 7273ace..eba7bb0 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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() { + + {/* 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. */} +
+ + + bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] }) + } + onRemove={(tagId) => + bulkRemoveTagsMutation.mutate({ + ids: selectedPhotos, + tagIds: [tagId], + }) + } + onCreate={(name) => { + createAndAttachMutation.mutate({ name, ids: selectedPhotos }) + setTagInput('') + }} + /> +
) } + +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 ( +
+ 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 && ( + + )} + + {filtered.length > 0 ? ( +
+ {filtered.map((tag) => ( + + + + + ))} +
+ ) : ( +
No tags match
+ )} +
+ ) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 55cdbed..d9d8464 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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) => {