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. */} +