feat: tags end-to-end (CRUD, photo membership, filter, sidebar UI)

The Tag model and photo_tags join table were already in place; this
fills in the rest — full backend CRUD, per-photo add/remove, list-
endpoint filtering, and a Tags section in the RightSidebar with
autocomplete-create.

Backend
- routers/tags.py rewritten from a 27-line stub:
    GET    /tags                — list with photo counts
    POST   /tags                — create (idempotent on name)
    PATCH  /tags/{id}           — rename / recolor
    DELETE /tags/{id}           — delete (FK cascades photo_tags)
- routers/photos.py:
    POST   /photos/{id}/tags    — add tag ids (idempotent)
    DELETE /photos/{id}/tags/{tag_id} — remove
    GET    /photos/{id}         — now returns a `tags` list alongside
                                  the existing PhotoResponse fields
                                  (fetched via the photo_tags join)
- list_photos applies the existing tag_ids query param: comma-
  separated, AND semantics, one IN-subquery per id since SQLite
  has no native set-contains-all.

Frontend
- New hooks/useTagsQuery.ts.
- services/api.ts: Tag interface, full tags client (list/create/
  update/delete), addToPhoto/removeFromPhoto helpers.
- filterStore: tagIds: string[] field, setTagIds, toggleTagId,
  hasActiveFilters update, filtersToParams sends tag_ids comma list.
- useFilterUrlSync round-trips ?tag_ids=… so tag-filtered views
  are bookmarkable.
- usePhotosQuery threads tagIds through.
- RightSidebar gains a new Tags section using a TagsEditor
  component:
    - shows existing tag chips with X to remove
    - autocomplete input that matches the user's typing against
      existing tag names
    - shows an inline "+ Create '<name>'" affordance when there's
      no exact match
    - Enter creates and attaches in one shot; Esc clears the input
    - existing colour values render as a tinted chip background
- FilterBar gets a Tags group (only rendered when there's at
  least one tag) with toggleable chips per tag.
- ActiveFilterChips shows "Tag: <name>" chips for each active
  tag id, looking up names lazily from the tags query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:20:32 +02:00
parent 63383ecf1c
commit bc1e63095c
10 changed files with 480 additions and 32 deletions

View File

@@ -24,6 +24,8 @@ export interface FilterState {
heapId: string | null
/** When set, restrict to photos in this folder. */
folderId: string | null
/** Restrict to photos that have ALL of these tag ids (AND semantics). */
tagIds: string[]
sortBy: SortField
sortOrder: SortOrder
}
@@ -40,6 +42,8 @@ interface FilterStore extends FilterState {
setFlag: (flag: FlagFilter) => void
setHeapId: (id: string | null) => void
setFolderId: (id: string | null) => void
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
@@ -61,6 +65,7 @@ export const INITIAL_FILTERS: FilterState = {
flag: 'any',
heapId: null,
folderId: null,
tagIds: [],
sortBy: 'taken_at',
sortOrder: 'desc',
}
@@ -83,6 +88,13 @@ export const useFilterStore = create<FilterStore>((set) => ({
setFlag: (flag) => set({ flag }),
setHeapId: (heapId) => set({ heapId }),
setFolderId: (folderId) => set({ folderId }),
setTagIds: (tagIds) => set({ tagIds }),
toggleTagId: (id) =>
set((s) => ({
tagIds: s.tagIds.includes(id)
? s.tagIds.filter((t) => t !== id)
: [...s.tagIds, id],
})),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>
@@ -108,6 +120,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
if (f.flag === 'discarded') params.is_discarded = 'true'
if (f.heapId) params.heap_id = f.heapId
if (f.folderId) params.folder_id = f.folderId
if (f.tagIds.length > 0) params.tag_ids = f.tagIds.join(',')
params.sort = f.sortBy
params.order = f.sortOrder
return params
@@ -124,6 +137,7 @@ export function hasActiveFilters(f: FilterState): boolean {
f.colorLabel !== null ||
f.flag !== 'any' ||
f.heapId !== null ||
f.folderId !== null
f.folderId !== null ||
f.tagIds.length > 0
)
}