From 7db093c696cae968f89cca586251618f4737e0f7 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 07:56:02 +0000 Subject: [PATCH] feat(07-02): frontend NL search bar wired to GET /api/search - web/src/lib/api.ts: add fetchSearch(q) returning Promise - web/src/components/inventory/FilterBar.tsx: NL search input with 400ms debounce, volt accent styling, Sparkles icon, Loader2 spinner during search - web/src/pages/DashboardPage.tsx: nlQuery state + useQuery hook (enabled >2 chars), displays searchResults when NL active, local filter unchanged when NL empty --- web/src/components/inventory/FilterBar.tsx | 149 ++++++++++++++------- web/src/lib/api.ts | 3 + web/src/pages/DashboardPage.tsx | 41 ++++-- 3 files changed, 133 insertions(+), 60 deletions(-) diff --git a/web/src/components/inventory/FilterBar.tsx b/web/src/components/inventory/FilterBar.tsx index 444c009..0103c57 100644 --- a/web/src/components/inventory/FilterBar.tsx +++ b/web/src/components/inventory/FilterBar.tsx @@ -1,4 +1,5 @@ -import { Search, LayoutGrid, List } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Search, LayoutGrid, List, Loader2, Sparkles } from 'lucide-react' import { Button } from '@/components/ui/button' import { useUIStore } from '@/store/ui' @@ -8,6 +9,9 @@ interface FilterBarProps { statusFilter: string onStatusChange: (v: string) => void totalCount: number + nlQuery: string + onNlQueryChange: (v: string) => void + nlSearchLoading: boolean } const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete'] @@ -20,61 +24,104 @@ const STATUS_LABELS: Record = { complete: 'Complete', } -export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) { +export function FilterBar({ + search, + onSearchChange, + statusFilter, + onStatusChange, + totalCount, + nlQuery, + onNlQueryChange, + nlSearchLoading, +}: FilterBarProps) { const { viewMode, setViewMode } = useUIStore() + // Local state for the NL input value — debounced before calling onNlQueryChange. + const [nlInputValue, setNlInputValue] = useState(nlQuery) + + // Sync if parent resets nlQuery to empty (e.g. clear action). + useEffect(() => { + if (nlQuery === '') setNlInputValue('') + }, [nlQuery]) + + // 400ms debounce: propagate to parent only after user stops typing. + useEffect(() => { + const timer = setTimeout(() => { + onNlQueryChange(nlInputValue) + }, 400) + return () => clearTimeout(timer) + }, [nlInputValue, onNlQueryChange]) + return ( -
- {/* Search */} -
- - onSearchChange(e.target.value)} - className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30" - /> +
+ {/* Row 1: text filter + status + view toggle */} +
+ {/* Local text search */} +
+ + onSearchChange(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30" + /> +
+ + {/* Status filter */} + + + {/* Item count */} + + {totalCount} items + + + {/* View toggle */} +
+ + +
- {/* Status filter */} - - - {/* Item count */} - - {totalCount} items - - - {/* View toggle */} -
- - + {/* Row 2: Natural language search */} +
+ + setNlInputValue(e.target.value)} + className="w-full pl-9 pr-9 py-2 bg-[#0a0a0a] border border-volt/20 rounded-card text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt focus:ring-1 focus:ring-volt/30 transition-colors" + /> + {nlSearchLoading && ( + + )}
) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 10723ed..7c8852a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -28,6 +28,9 @@ async function fetchJSON(url: string): Promise { export const fetchInventory = (): Promise => fetchJSON(`${BASE}/inventory`) +export const fetchSearch = (q: string): Promise => + fetchJSON(`${BASE}/search?q=${encodeURIComponent(q)}`) + export const fetchInventoryItem = (id: number): Promise => fetchJSON(`${BASE}/inventory/${id}`) diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index 57167a7..aa7017e 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -1,10 +1,12 @@ import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { AppShell } from '@/components/layout/AppShell' import { FilterBar } from '@/components/inventory/FilterBar' import { ItemCard } from '@/components/inventory/ItemCard' import { ItemRow } from '@/components/inventory/ItemRow' import { useInventory } from '@/hooks/useInventory' import { useUIStore } from '@/store/ui' +import { fetchSearch } from '@/lib/api' import { Loader2, AlertCircle } from 'lucide-react' export function DashboardPage() { @@ -12,7 +14,17 @@ export function DashboardPage() { const { viewMode } = useUIStore() const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('') + const [nlQuery, setNlQuery] = useState('') + // NL search via GET /api/search — only fires when nlQuery has > 2 chars. + const { data: searchResults, isLoading: searchLoading } = useQuery({ + queryKey: ['search', nlQuery], + queryFn: () => fetchSearch(nlQuery), + enabled: nlQuery.trim().length > 2, + staleTime: 30_000, + }) + + // Local filter (used when nlQuery is empty). const filtered = useMemo(() => { if (!items) return [] return items.filter((item) => { @@ -26,6 +38,10 @@ export function DashboardPage() { }) }, [items, search, statusFilter]) + // When nlQuery is active (> 2 chars), display NL results; otherwise local filter. + const displayItems = nlQuery.trim().length > 2 ? (searchResults ?? []) : filtered + const displayLoading = nlQuery.trim().length > 2 ? searchLoading : isLoading + return ( {/* Page header */} @@ -39,14 +55,17 @@ export function DashboardPage() { onSearchChange={setSearch} statusFilter={statusFilter} onStatusChange={setStatusFilter} - totalCount={filtered.length} + totalCount={displayItems.length} + nlQuery={nlQuery} + onNlQueryChange={setNlQuery} + nlSearchLoading={searchLoading} /> {/* Loading */} - {isLoading && ( + {displayLoading && (
- Loading inventory… + {nlQuery.trim().length > 2 ? 'Searching…' : 'Loading inventory…'}
)} @@ -59,28 +78,32 @@ export function DashboardPage() { )} {/* Empty state */} - {!isLoading && !error && filtered.length === 0 && ( + {!displayLoading && !error && displayItems.length === 0 && (

0

- {items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'} + {nlQuery.trim().length > 2 + ? 'No items match your search' + : items && items.length > 0 + ? 'No items match your filters' + : 'No items cataloged yet — add your first item'}

)} {/* Grid view */} - {!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && ( + {!displayLoading && !error && displayItems.length > 0 && viewMode === 'grid' && (
- {filtered.map((item) => ( + {displayItems.map((item) => ( ))}
)} {/* List view */} - {!isLoading && !error && filtered.length > 0 && viewMode === 'list' && ( + {!displayLoading && !error && displayItems.length > 0 && viewMode === 'list' && (
- {filtered.map((item) => ( + {displayItems.map((item) => ( ))}