From 29177f0c1a069f8cabb4f82cfcce9adbac29c871 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Apr 2026 09:19:47 +0200 Subject: [PATCH] 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) --- .../src/components/layout/LeftSidebar.tsx | 12 ++++- frontend/src/hooks/useSearchQuery.ts | 37 ++++++++++++++ frontend/src/hooks/useTagsQuery.ts | 8 +-- frontend/src/services/api.ts | 51 +++++++++++++++++-- 4 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 frontend/src/hooks/useSearchQuery.ts diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index d47cbbd..346fa7b 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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(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: , count: stats?.all_photos ?? 0 }, { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, + { id: 'people', label: 'People', icon: , count: peopleTotalCount }, { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, { id: 'duplicates', label: 'Duplicates', icon: , count: stats?.duplicates ?? 0 }, diff --git a/frontend/src/hooks/useSearchQuery.ts b/frontend/src/hooks/useSearchQuery.ts new file mode 100644 index 0000000..4b46275 --- /dev/null +++ b/frontend/src/hooks/useSearchQuery.ts @@ -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({ + 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, + }) +} diff --git a/frontend/src/hooks/useTagsQuery.ts b/frontend/src/hooks/useTagsQuery.ts index 78fbfbb..7096115 100644 --- a/frontend/src/hooks/useTagsQuery.ts +++ b/frontend/src/hooks/useTagsQuery.ts @@ -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({ - queryKey: TAGS_QUERY_KEY, - queryFn: tagsApi.list, + queryKey: kind ? ['tags', kind] : TAGS_QUERY_KEY, + queryFn: () => tagsApi.list(kind), staleTime: 30_000, }) } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index c8e4a26..d86122e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 => { - const response = await api.get('/tags') + list: async (kind?: TagKind): Promise => { + const params = kind ? { kind } : undefined + const response = await api.get('/tags', { params }) return response.data }, - create: async (name: string, color?: string): Promise => { - const response = await api.post('/tags', { name, color }) + create: async (name: string, color?: string, kind: TagKind = 'user'): Promise => { + 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 () => {