Split the 316-line vendors index into VendorToolbar, VendorGrid, VendorList, and PerPageSelector under components/vendors/. Page now owns only search-param state and query orchestration. Replace generic Error throws in lib/directus.ts with a DirectusError class carrying status, statusText, collection, url, and parsed body. Surface useLocalStorage read/write failures via console.warn instead of swallowing them.
114 lines
3.6 KiB
TypeScript
114 lines
3.6 KiB
TypeScript
import { Check, Filter, FilterX, LayoutGrid, List, Loader2, Search, X } from 'lucide-react'
|
|
|
|
import { Button } from '~/components/ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '~/components/ui/dropdown-menu'
|
|
import { Input } from '~/components/ui/input'
|
|
import type { Category } from '~/lib/types'
|
|
|
|
import type { VendorLayout } from './types'
|
|
|
|
type Props = {
|
|
searchInput: string
|
|
onSearchInput: (value: string) => void
|
|
onSearchClear: () => void
|
|
isFetching: boolean
|
|
categories: Category[]
|
|
category: string
|
|
onCategoryChange: (slug: string | undefined) => void
|
|
layout: VendorLayout
|
|
onLayoutChange: (layout: VendorLayout) => void
|
|
}
|
|
|
|
export function VendorToolbar({
|
|
searchInput,
|
|
onSearchInput,
|
|
onSearchClear,
|
|
isFetching,
|
|
categories,
|
|
category,
|
|
onCategoryChange,
|
|
layout,
|
|
onLayoutChange,
|
|
}: Props) {
|
|
return (
|
|
<div className="mb-5 flex flex-col items-stretch gap-4 md:flex-row md:items-center md:justify-between">
|
|
<h1 className="text-2xl font-bold">Vendors</h1>
|
|
<div className="flex h-9 items-center justify-center">
|
|
{isFetching && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="pl-9 pr-9"
|
|
placeholder="Search vendors"
|
|
value={searchInput}
|
|
onChange={(e) => onSearchInput(e.target.value.toLowerCase())}
|
|
/>
|
|
{searchInput && (
|
|
<button
|
|
type="button"
|
|
aria-label="Clear search"
|
|
onClick={onSearchClear}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="icon" aria-label="Filter by category">
|
|
{category ? <FilterX /> : <Filter />}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{categories.map((cat) => (
|
|
<DropdownMenuItem
|
|
key={cat.slug}
|
|
disabled={category === cat.slug}
|
|
onSelect={() => onCategoryChange(cat.slug)}
|
|
>
|
|
{category === cat.slug && <Check className="h-4 w-4" />}
|
|
{cat.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onSelect={() => onCategoryChange(undefined)}>Clear</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="inline-flex rounded-md border">
|
|
<Button
|
|
variant={layout === 'GRID' ? 'secondary' : 'ghost'}
|
|
size="icon"
|
|
className="rounded-r-none"
|
|
onClick={() => onLayoutChange('GRID')}
|
|
aria-label="Grid layout"
|
|
disabled={layout === 'GRID'}
|
|
>
|
|
<LayoutGrid />
|
|
</Button>
|
|
<Button
|
|
variant={layout === 'LIST' ? 'secondary' : 'ghost'}
|
|
size="icon"
|
|
className="rounded-l-none border-l"
|
|
onClick={() => onLayoutChange('LIST')}
|
|
aria-label="List layout"
|
|
disabled={layout === 'LIST'}
|
|
>
|
|
<List />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|