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:
parent
9db7707a64
commit
7db093c696
3 changed files with 133 additions and 60 deletions
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue