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:
@@ -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 },
|
||||
|
||||
37
frontend/src/hooks/useSearchQuery.ts
Normal file
37
frontend/src/hooks/useSearchQuery.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user