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:
@@ -191,32 +191,43 @@ export const heaps = {
|
||||
}
|
||||
|
||||
// Tags API
|
||||
export interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
photo_count: number
|
||||
}
|
||||
|
||||
export const tags = {
|
||||
list: async () => {
|
||||
list: async (): Promise<Tag[]> => {
|
||||
const response = await api.get('/tags')
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (name: string, color?: string) => {
|
||||
const response = await api.post('/tags', {
|
||||
name,
|
||||
color,
|
||||
})
|
||||
create: async (name: string, color?: string): Promise<Tag> => {
|
||||
const response = await api.post('/tags', { name, color })
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (tagId: string, data: {
|
||||
name?: string
|
||||
color?: string
|
||||
}) => {
|
||||
update: async (tagId: string, data: { name?: string; color?: string }): Promise<Tag> => {
|
||||
const response = await api.patch(`/tags/${tagId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (tagId: string) => {
|
||||
const response = await api.delete(`/tags/${tagId}`)
|
||||
delete: async (tagId: string): Promise<void> => {
|
||||
await api.delete(`/tags/${tagId}`)
|
||||
},
|
||||
|
||||
/** Add one or more tags to a photo. */
|
||||
addToPhoto: async (photoId: string, tagIds: string[]) => {
|
||||
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Remove a tag from a photo. */
|
||||
removeFromPhoto: async (photoId: string, tagId: string): Promise<void> => {
|
||||
await api.delete(`/photos/${photoId}/tags/${tagId}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Discard API
|
||||
|
||||
Reference in New Issue
Block a user