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) => {