feat(07-02): frontend NL search bar wired to GET /api/search

- web/src/lib/api.ts: add fetchSearch(q) returning Promise<InventoryItem[]>
- 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
This commit is contained in:
Mikkel Georgsen 2026-04-10 07:56:02 +00:00
parent 9db7707a64
commit 7db093c696
3 changed files with 133 additions and 60 deletions

View file

@ -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 { Button } from '@/components/ui/button'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
@ -8,6 +9,9 @@ interface FilterBarProps {
statusFilter: string statusFilter: string
onStatusChange: (v: string) => void onStatusChange: (v: string) => void
totalCount: number totalCount: number
nlQuery: string
onNlQueryChange: (v: string) => void
nlSearchLoading: boolean
} }
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete'] const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
@ -20,61 +24,104 @@ const STATUS_LABELS: Record<string, string> = {
complete: 'Complete', 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() 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 ( return (
<div className="flex flex-wrap items-center gap-3 mb-6"> <div className="flex flex-col gap-3 mb-6">
{/* Search */} {/* Row 1: text filter + status + view toggle */}
<div className="relative flex-1 min-w-48"> <div className="flex flex-wrap items-center gap-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" /> {/* Local text search */}
<input <div className="relative flex-1 min-w-48">
type="text" <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
placeholder="Search items…" <input
value={search} type="text"
onChange={(e) => onSearchChange(e.target.value)} placeholder="Search items…"
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" value={search}
/> onChange={(e) => 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"
/>
</div>
{/* Status filter */}
<select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
>
{STATUSES.map((s) => (
<option key={s} value={s} className="bg-near-black">
{STATUS_LABELS[s]}
</option>
))}
</select>
{/* Item count */}
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
{totalCount} items
</span>
{/* View toggle */}
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9"
onClick={() => setViewMode('grid')}
title="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9 border-l border-charcoal/80"
onClick={() => setViewMode('list')}
title="List view"
>
<List className="w-4 h-4" />
</Button>
</div>
</div> </div>
{/* Status filter */} {/* Row 2: Natural language search */}
<select <div className="relative w-full">
value={statusFilter} <Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-volt/60" />
onChange={(e) => onStatusChange(e.target.value)} <input
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60" type="text"
> placeholder="Ask anything: show me free 10GbE NICs…"
{STATUSES.map((s) => ( value={nlInputValue}
<option key={s} value={s} className="bg-near-black"> onChange={(e) => setNlInputValue(e.target.value)}
{STATUS_LABELS[s]} 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"
</option> />
))} {nlSearchLoading && (
</select> <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-volt" />
)}
{/* Item count */}
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
{totalCount} items
</span>
{/* View toggle */}
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9"
onClick={() => setViewMode('grid')}
title="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'secondary'}
size="icon"
className="rounded-none h-9 w-9 border-l border-charcoal/80"
onClick={() => setViewMode('list')}
title="List view"
>
<List className="w-4 h-4" />
</Button>
</div> </div>
</div> </div>
) )

View file

@ -28,6 +28,9 @@ async function fetchJSON<T>(url: string): Promise<T> {
export const fetchInventory = (): Promise<InventoryItem[]> => export const fetchInventory = (): Promise<InventoryItem[]> =>
fetchJSON<InventoryItem[]>(`${BASE}/inventory`) fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
export const fetchSearch = (q: string): Promise<InventoryItem[]> =>
fetchJSON<InventoryItem[]>(`${BASE}/search?q=${encodeURIComponent(q)}`)
export const fetchInventoryItem = (id: number): Promise<InventoryItem> => export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`) fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)

View file

@ -1,10 +1,12 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { AppShell } from '@/components/layout/AppShell' import { AppShell } from '@/components/layout/AppShell'
import { FilterBar } from '@/components/inventory/FilterBar' import { FilterBar } from '@/components/inventory/FilterBar'
import { ItemCard } from '@/components/inventory/ItemCard' import { ItemCard } from '@/components/inventory/ItemCard'
import { ItemRow } from '@/components/inventory/ItemRow' import { ItemRow } from '@/components/inventory/ItemRow'
import { useInventory } from '@/hooks/useInventory' import { useInventory } from '@/hooks/useInventory'
import { useUIStore } from '@/store/ui' import { useUIStore } from '@/store/ui'
import { fetchSearch } from '@/lib/api'
import { Loader2, AlertCircle } from 'lucide-react' import { Loader2, AlertCircle } from 'lucide-react'
export function DashboardPage() { export function DashboardPage() {
@ -12,7 +14,17 @@ export function DashboardPage() {
const { viewMode } = useUIStore() const { viewMode } = useUIStore()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = 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(() => { const filtered = useMemo(() => {
if (!items) return [] if (!items) return []
return items.filter((item) => { return items.filter((item) => {
@ -26,6 +38,10 @@ export function DashboardPage() {
}) })
}, [items, search, statusFilter]) }, [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 ( return (
<AppShell> <AppShell>
{/* Page header */} {/* Page header */}
@ -39,14 +55,17 @@ export function DashboardPage() {
onSearchChange={setSearch} onSearchChange={setSearch}
statusFilter={statusFilter} statusFilter={statusFilter}
onStatusChange={setStatusFilter} onStatusChange={setStatusFilter}
totalCount={filtered.length} totalCount={displayItems.length}
nlQuery={nlQuery}
onNlQueryChange={setNlQuery}
nlSearchLoading={searchLoading}
/> />
{/* Loading */} {/* Loading */}
{isLoading && ( {displayLoading && (
<div className="flex items-center justify-center py-24 text-[#a0a0a0]"> <div className="flex items-center justify-center py-24 text-[#a0a0a0]">
<Loader2 className="w-6 h-6 animate-spin mr-2" /> <Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading inventory {nlQuery.trim().length > 2 ? 'Searching…' : 'Loading inventory…'}
</div> </div>
)} )}
@ -59,28 +78,32 @@ export function DashboardPage() {
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !error && filtered.length === 0 && ( {!displayLoading && !error && displayItems.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center"> <div className="flex flex-col items-center justify-center py-24 text-center">
<p className="font-display font-black text-4xl text-volt mb-2">0</p> <p className="font-display font-black text-4xl text-volt mb-2">0</p>
<p className="text-[#a0a0a0] text-sm"> <p className="text-[#a0a0a0] text-sm">
{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'}
</p> </p>
</div> </div>
)} )}
{/* Grid view */} {/* Grid view */}
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && ( {!displayLoading && !error && displayItems.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filtered.map((item) => ( {displayItems.map((item) => (
<ItemCard key={item.id} item={item} /> <ItemCard key={item.id} item={item} />
))} ))}
</div> </div>
)} )}
{/* List view */} {/* List view */}
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && ( {!displayLoading && !error && displayItems.length > 0 && viewMode === 'list' && (
<div className="border border-charcoal/80 rounded-card overflow-hidden"> <div className="border border-charcoal/80 rounded-card overflow-hidden">
{filtered.map((item) => ( {displayItems.map((item) => (
<ItemRow key={item.id} item={item} /> <ItemRow key={item.id} item={item} />
))} ))}
</div> </div>