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:
@@ -18,8 +18,16 @@ import { usePhotoStore } from '../../store/photoStore'
|
||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { tags as tagsApi, type Tag } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
|
||||
interface PhotoTagSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
interface PhotoDetails {
|
||||
id: string
|
||||
filename: string
|
||||
@@ -34,6 +42,7 @@ interface PhotoDetails {
|
||||
user_notes: string | null
|
||||
color_label: string | null
|
||||
exif_json: string | null
|
||||
tags?: PhotoTagSummary[]
|
||||
}
|
||||
|
||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||
@@ -100,7 +109,7 @@ export function RightSidebar() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['basic', 'camera', 'location'])
|
||||
new Set(['basic', 'camera', 'location', 'tags'])
|
||||
)
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
@@ -174,6 +183,46 @@ export function RightSidebar() {
|
||||
},
|
||||
})
|
||||
|
||||
// ── Tags state + mutations ──────────────────────────────────────────
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const invalidateTagsAndPhoto = () => {
|
||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||
}
|
||||
|
||||
const addTagMutation = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
// Idempotent create — backend returns existing row if name matches.
|
||||
const created = await tagsApi.create(name)
|
||||
if (activePhotoId) {
|
||||
await tagsApi.addToPhoto(activePhotoId, [created.id])
|
||||
}
|
||||
return created
|
||||
},
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const attachExistingTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) =>
|
||||
tagsApi.addToPhoto(activePhotoId!, [tagId]),
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
const removeTagMutation = useMutation({
|
||||
mutationFn: (tagId: string) =>
|
||||
tagsApi.removeFromPhoto(activePhotoId!, tagId),
|
||||
onSuccess: () => invalidateTagsAndPhoto(),
|
||||
onError: (e: any) =>
|
||||
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
||||
})
|
||||
|
||||
// Local drafts for the editable text fields. These mirror the server value
|
||||
// but stay independent while the user is typing, so we don't fight focus or
|
||||
// clobber edits with stale refetches.
|
||||
@@ -520,6 +569,26 @@ export function RightSidebar() {
|
||||
<div className="text-xs text-text-muted">No GPS data</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Tags */}
|
||||
<Section
|
||||
title="Tags"
|
||||
expanded={expandedSections.has('tags')}
|
||||
onToggle={() => toggleSection('tags')}
|
||||
>
|
||||
<TagsEditor
|
||||
photoTags={photo.tags ?? []}
|
||||
allTags={allTags}
|
||||
tagInput={tagInput}
|
||||
onTagInputChange={setTagInput}
|
||||
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
||||
onCreateAndAttach={(name) => {
|
||||
addTagMutation.mutate(name)
|
||||
setTagInput('')
|
||||
}}
|
||||
onRemove={(id) => removeTagMutation.mutate(id)}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -574,6 +643,129 @@ function Section({
|
||||
)
|
||||
}
|
||||
|
||||
interface TagsEditorProps {
|
||||
photoTags: PhotoTagSummary[]
|
||||
allTags: Tag[]
|
||||
tagInput: string
|
||||
onTagInputChange: (value: string) => void
|
||||
onAttachExisting: (id: string) => void
|
||||
onCreateAndAttach: (name: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function TagsEditor({
|
||||
photoTags,
|
||||
allTags,
|
||||
tagInput,
|
||||
onTagInputChange,
|
||||
onAttachExisting,
|
||||
onCreateAndAttach,
|
||||
onRemove,
|
||||
}: TagsEditorProps) {
|
||||
const trimmed = tagInput.trim()
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
const photoTagIds = new Set(photoTags.map((t) => t.id))
|
||||
|
||||
// Suggestions: tags whose name contains the input AND that aren't
|
||||
// already on the photo. Capped at 6 to keep the dropdown short.
|
||||
const suggestions = trimmed
|
||||
? allTags
|
||||
.filter(
|
||||
(t) =>
|
||||
!photoTagIds.has(t.id) &&
|
||||
t.name.toLowerCase().includes(lowerTrimmed)
|
||||
)
|
||||
.slice(0, 6)
|
||||
: []
|
||||
|
||||
const exactMatch = trimmed
|
||||
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
|
||||
: null
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!trimmed) return
|
||||
if (exactMatch) {
|
||||
if (!photoTagIds.has(exactMatch.id)) {
|
||||
onAttachExisting(exactMatch.id)
|
||||
}
|
||||
onTagInputChange('')
|
||||
} else {
|
||||
onCreateAndAttach(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Existing tag chips */}
|
||||
{photoTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{photoTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
||||
style={tag.color ? { backgroundColor: `${tag.color}33`, color: tag.color } : undefined}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => onRemove(tag.id)}
|
||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
|
||||
title="Remove tag"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-text-faint">No tags</div>
|
||||
)}
|
||||
|
||||
{/* Add tag input + suggestions */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
onTagInputChange('')
|
||||
}
|
||||
}}
|
||||
placeholder="Add tag…"
|
||||
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"
|
||||
/>
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-1 rounded border border-border bg-bg shadow-md">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
onAttachExisting(s.id)
|
||||
onTagInputChange('')
|
||||
}}
|
||||
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{trimmed && !exactMatch && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
|
||||
>
|
||||
+ Create "{trimmed}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user