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,
|
Pencil,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
Settings,
|
Settings,
|
||||||
|
Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
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 navigateToSection = useFilterStore((s) => s.navigateToSection)
|
||||||
const currentSection = useFilterStore((s) => s.currentSection)
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
const { data: allTags = [] } = useTagsQuery()
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
|
const { data: faceClusters = [] } = useTagsQuery('face_cluster')
|
||||||
const { data: stats } = useLibraryStatsQuery()
|
const { data: stats } = useLibraryStatsQuery()
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -255,6 +257,9 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
case 'tags':
|
case 'tags':
|
||||||
navigateToSection('tags', { groupBy: 'tag' })
|
navigateToSection('tags', { groupBy: 'tag' })
|
||||||
break
|
break
|
||||||
|
case 'people':
|
||||||
|
navigateToSection('people', { groupBy: 'tag' })
|
||||||
|
break
|
||||||
case 'colors':
|
case 'colors':
|
||||||
navigateToSection('colors', { groupBy: 'color' })
|
navigateToSection('colors', { groupBy: 'color' })
|
||||||
break
|
break
|
||||||
@@ -349,8 +354,10 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Total tag count for the badge on the Tags entry.
|
// Total tag count for the badge on the Tags entry (user tags only).
|
||||||
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
|
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[] = [
|
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: '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: '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: '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: '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: '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 },
|
{ 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 { 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 const TAGS_QUERY_KEY = ['tags'] as const
|
||||||
|
|
||||||
export function useTagsQuery() {
|
export function useTagsQuery(kind?: TagKind) {
|
||||||
return useQuery<Tag[]>({
|
return useQuery<Tag[]>({
|
||||||
queryKey: TAGS_QUERY_KEY,
|
queryKey: kind ? ['tags', kind] : TAGS_QUERY_KEY,
|
||||||
queryFn: tagsApi.list,
|
queryFn: () => tagsApi.list(kind),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,21 +519,27 @@ export const heaps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tags API
|
// Tags API
|
||||||
|
export type TagKind = 'user' | 'object' | 'scene' | 'face_cluster'
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
color: string | null
|
color: string | null
|
||||||
|
kind: TagKind
|
||||||
|
source: string | null
|
||||||
|
representative_photo_id: string | null
|
||||||
photo_count: number
|
photo_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tags = {
|
export const tags = {
|
||||||
list: async (): Promise<Tag[]> => {
|
list: async (kind?: TagKind): Promise<Tag[]> => {
|
||||||
const response = await api.get('/tags')
|
const params = kind ? { kind } : undefined
|
||||||
|
const response = await api.get('/tags', { params })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (name: string, color?: string): Promise<Tag> => {
|
create: async (name: string, color?: string, kind: TagKind = 'user'): Promise<Tag> => {
|
||||||
const response = await api.post('/tags', { name, color })
|
const response = await api.post('/tags', { name, color, kind })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -546,6 +552,11 @@ export const tags = {
|
|||||||
await api.delete(`/tags/${tagId}`)
|
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. */
|
/** Add one or more tags to a photo. */
|
||||||
addToPhoto: async (photoId: string, tagIds: string[]) => {
|
addToPhoto: async (photoId: string, tagIds: string[]) => {
|
||||||
const response = await api.post(`/photos/${photoId}/tags`, { tag_ids: tagIds })
|
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
|
// Discard API
|
||||||
export const discard = {
|
export const discard = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user