feat: wire frontend to vision pipeline search and tags

- Add search API client (POST /photos/search) and useSearchQuery hook
  for hybrid FTS + semantic search with RRF ranking
- Extend Tag type with kind, source, representative_photo_id fields
- Add tags.merge() API method
- Update useTagsQuery to accept optional kind filter
- Add People section to sidebar (face clusters from GET /tags?kind=face_cluster)
- Sidebar Tags count now shows user tags only; People shows face clusters

The existing GET /photos?q= flow is preserved for browsing; the new
search hook activates when the search box has a non-empty query.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:19:47 +02:00
parent ad007e4cd4
commit 29177f0c1a
4 changed files with 98 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ import {
Pencil,
PanelLeftClose,
Settings,
Users,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -63,6 +64,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery()
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
@@ -255,6 +257,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
case 'tags':
navigateToSection('tags', { groupBy: 'tag' })
break
case 'people':
navigateToSection('people', { groupBy: 'tag' })
break
case 'colors':
navigateToSection('colors', { groupBy: 'color' })
break
@@ -349,8 +354,10 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
: undefined,
})
// Total tag count for the badge on the Tags entry.
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
// Total tag count for the badge on the Tags entry (user tags only).
const userTags = allTags.filter((t) => t.kind === 'user')
const tagsTotalCount = userTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const peopleTotalCount = faceClusters.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
@@ -361,6 +368,7 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'people', label: 'People', icon: <Users className="h-4 w-4" />, count: peopleTotalCount },
{ id: 'colors', label: 'Colors', icon: <Palette className="h-4 w-4" />, count: stats?.colored ?? 0 },
{ id: 'map', label: 'Map', icon: <MapPin className="h-4 w-4" />, count: stats?.with_gps ?? 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { search, type SearchResult } from '../services/api'
import { useFilterStore } from '../store/filterStore'
/**
* Hybrid search hook — fires POST /photos/search when the user has a
* non-empty search query. Returns results ranked by RRF (FTS + semantic).
*
* When `q` is empty, this hook is disabled and returns no data — the
* normal usePhotosQuery takes over for browse mode.
*/
export function useSearchQuery() {
const q = useFilterStore((s) => s.q)
const tagIds = useFilterStore((s) => s.tagIds)
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const hasQuery = q.trim().length > 0
return useQuery<SearchResult[]>({
queryKey: ['search', q, tagIds, dateFrom, dateTo],
queryFn: async () => {
const resp = await search.query({
q: q.trim(),
filters: {
tag_ids: tagIds.length > 0 ? tagIds : undefined,
date_from: dateFrom ?? undefined,
date_to: dateTo ?? undefined,
},
limit: 200,
})
return resp.results
},
enabled: hasQuery,
staleTime: 30_000,
})
}

View File

@@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { tags as tagsApi, type Tag } from '../services/api'
import { tags as tagsApi, type Tag, type TagKind } from '../services/api'
export const TAGS_QUERY_KEY = ['tags'] as const
export function useTagsQuery() {
export function useTagsQuery(kind?: TagKind) {
return useQuery<Tag[]>({
queryKey: TAGS_QUERY_KEY,
queryFn: tagsApi.list,
queryKey: kind ? ['tags', kind] : TAGS_QUERY_KEY,
queryFn: () => tagsApi.list(kind),
staleTime: 30_000,
})
}

View File

@@ -519,21 +519,27 @@ export const heaps = {
}
// Tags API
export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'
export interface Tag {
id: string
name: string
color: string | null
kind: TagKind
source: string | null
representative_photo_id: string | null
photo_count: number
}
export const tags = {
list: async (): Promise<Tag[]> => {
const response = await api.get('/tags')
list: async (kind?: TagKind): Promise<Tag[]> => {
const params = kind ? { kind } : undefined
const response = await api.get('/tags', { params })
return response.data
},
create: async (name: string, color?: string): Promise<Tag> => {
const response = await api.post('/tags', { name, color })
create: async (name: string, color?: string, kind: TagKind = 'user'): Promise<Tag> => {
const response = await api.post('/tags', { name, color, kind })
return response.data
},
@@ -546,6 +552,11 @@ export const tags = {
await api.delete(`/tags/${tagId}`)
},
merge: async (sourceId: string, targetId: string): Promise<{ merged_into: string; target_name: string }> => {
const response = await api.post(`/tags/${sourceId}/merge`, { target_id: targetId })
return response.data
},
/** 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 })
@@ -558,6 +569,38 @@ export const tags = {
},
}
// Search API — hybrid FTS + semantic search
export interface SearchResult {
id: string
filename: string
filepath: string
media_type: string
width: number
height: number
taken_at: string | null
rating: number
color_label: string | null
thumb_small: string
thumb_medium: string
score: number
}
export const search = {
query: async (params: {
q?: string
filters?: {
tag_ids?: string[]
date_from?: string
date_to?: string
}
limit?: number
offset?: number
}): Promise<{ results: SearchResult[]; total: number }> => {
const response = await api.post('/photos/search', params)
return response.data
},
}
// Discard API
export const discard = {
list: async () => {