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 { 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<string, string> = {
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 (
<div className="flex flex-wrap items-center gap-3 mb-6">
{/* Search */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
<input
type="text"
placeholder="Search items…"
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 className="flex flex-col gap-3 mb-6">
{/* Row 1: text filter + status + view toggle */}
<div className="flex flex-wrap items-center gap-3">
{/* Local text search */}
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
<input
type="text"
placeholder="Search items…"
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>
{/* 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>
{/* Row 2: Natural language search */}
<div className="relative w-full">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-volt/60" />
<input
type="text"
placeholder="Ask anything: show me free 10GbE NICs…"
value={nlInputValue}
onChange={(e) => 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 && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-volt" />
)}
</div>
</div>
)

View file

@ -28,6 +28,9 @@ async function fetchJSON<T>(url: string): Promise<T> {
export const fetchInventory = (): Promise<InventoryItem[]> =>
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> =>
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)

View file

@ -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 (
<AppShell>
{/* 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 && (
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading inventory
{nlQuery.trim().length > 2 ? 'Searching…' : 'Loading inventory…'}
</div>
)}
@ -59,28 +78,32 @@ export function DashboardPage() {
)}
{/* 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">
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
<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>
</div>
)}
{/* 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">
{filtered.map((item) => (
{displayItems.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
)}
{/* 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">
{filtered.map((item) => (
{displayItems.map((item) => (
<ItemRow key={item.id} item={item} />
))}
</div>