diff --git a/frontend/src/components/vendors/per-page-selector.tsx b/frontend/src/components/vendors/per-page-selector.tsx
new file mode 100644
index 0000000..16eb42f
--- /dev/null
+++ b/frontend/src/components/vendors/per-page-selector.tsx
@@ -0,0 +1,37 @@
+import { Button } from '~/components/ui/button'
+import { cn } from '~/lib/utils'
+
+type Props = {
+ value: number
+ options?: readonly number[]
+ onChange: (value: number) => void
+}
+
+const DEFAULT_OPTIONS = [12, 24, 48] as const
+
+export function PerPageSelector({ value, options = DEFAULT_OPTIONS, onChange }: Props) {
+ return (
+
+
Results per page:
+
+ {options.map((n, i) => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/vendors/types.ts b/frontend/src/components/vendors/types.ts
new file mode 100644
index 0000000..93e7a8c
--- /dev/null
+++ b/frontend/src/components/vendors/types.ts
@@ -0,0 +1,8 @@
+export type VendorLayout = 'GRID' | 'LIST'
+
+export function truncateDescription(desc: string | null | undefined): string {
+ if (!desc) return ''
+ const head = desc.substring(0, 80)
+ const end = head.lastIndexOf(' ')
+ return desc.substring(0, end > 0 ? end : head.length) + ' …'
+}
diff --git a/frontend/src/components/vendors/vendor-grid.tsx b/frontend/src/components/vendors/vendor-grid.tsx
new file mode 100644
index 0000000..8047433
--- /dev/null
+++ b/frontend/src/components/vendors/vendor-grid.tsx
@@ -0,0 +1,53 @@
+import { Link } from '@tanstack/react-router'
+
+import { Card, CardContent } from '~/components/ui/card'
+import { Skeleton } from '~/components/ui/skeleton'
+import { assetUrl } from '~/lib/directus'
+import type { VendorListItem } from '~/lib/types'
+
+import { truncateDescription } from './types'
+
+type Props = {
+ vendors: VendorListItem[]
+ isLoading: boolean
+ perPage: number
+}
+
+export function VendorGrid({ vendors, isLoading, perPage }: Props) {
+ return (
+
+ {isLoading
+ ? Array.from({ length: perPage }).map((_, k) => (
+
+
+
+
+
+
+
+
+ ))
+ : vendors.map((v) => (
+
+
+
+
+ {v.name}
+
+ {truncateDescription(v.description)}
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/vendors/vendor-list.tsx b/frontend/src/components/vendors/vendor-list.tsx
new file mode 100644
index 0000000..8fdc62b
--- /dev/null
+++ b/frontend/src/components/vendors/vendor-list.tsx
@@ -0,0 +1,57 @@
+import { Link } from '@tanstack/react-router'
+
+import { Skeleton } from '~/components/ui/skeleton'
+import { assetUrl } from '~/lib/directus'
+import type { VendorListItem } from '~/lib/types'
+
+import { truncateDescription } from './types'
+
+type Props = {
+ vendors: VendorListItem[]
+ isLoading: boolean
+ perPage: number
+}
+
+export function VendorList({ vendors, isLoading, perPage }: Props) {
+ return (
+
+
+
+ {isLoading
+ ? Array.from({ length: perPage }).map((_, k) => (
+
+ |
+
+ |
+
+
+ |
+
+
+ |
+
+ ))
+ : vendors.map((v) => (
+
+
+
+ |
+
+
+ {v.name}
+
+ |
+
+ {truncateDescription(v.description)}
+ |
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/components/vendors/vendor-toolbar.tsx b/frontend/src/components/vendors/vendor-toolbar.tsx
new file mode 100644
index 0000000..b8946f5
--- /dev/null
+++ b/frontend/src/components/vendors/vendor-toolbar.tsx
@@ -0,0 +1,113 @@
+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 (
+
+
Vendors
+
+ {isFetching && }
+
+
+
+
+ onSearchInput(e.target.value.toLowerCase())}
+ />
+ {searchInput && (
+
+ )}
+
+
+
+
+
+
+
+ {categories.map((cat) => (
+ onCategoryChange(cat.slug)}
+ >
+ {category === cat.slug && }
+ {cat.name}
+
+ ))}
+
+ onCategoryChange(undefined)}>Clear
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/hooks/use-local-storage.ts b/frontend/src/hooks/use-local-storage.ts
index 1283616..e1e58e3 100644
--- a/frontend/src/hooks/use-local-storage.ts
+++ b/frontend/src/hooks/use-local-storage.ts
@@ -6,7 +6,8 @@ export function useLocalStorage(key: string, initialValue: T): [T, (v: T) =>
try {
const raw = window.localStorage.getItem(key)
return raw != null ? (JSON.parse(raw) as T) : initialValue
- } catch {
+ } catch (err) {
+ console.warn(`useLocalStorage: failed to read "${key}"`, err)
return initialValue
}
})
@@ -14,8 +15,8 @@ export function useLocalStorage(key: string, initialValue: T): [T, (v: T) =>
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value))
- } catch {
- /* storage unavailable */
+ } catch (err) {
+ console.warn(`useLocalStorage: failed to write "${key}"`, err)
}
}, [key, value])
diff --git a/frontend/src/lib/directus.ts b/frontend/src/lib/directus.ts
index 7065dca..5b7fab3 100644
--- a/frontend/src/lib/directus.ts
+++ b/frontend/src/lib/directus.ts
@@ -20,6 +20,30 @@ export type DirectusQuery = {
meta?: string[]
}
+export class DirectusError extends Error {
+ readonly status: number
+ readonly statusText: string
+ readonly collection: string
+ readonly url: string
+ readonly body: unknown
+
+ constructor(args: {
+ status: number
+ statusText: string
+ collection: string
+ url: string
+ body: unknown
+ }) {
+ super(`Directus ${args.status} ${args.statusText} on /items/${args.collection}`)
+ this.name = 'DirectusError'
+ this.status = args.status
+ this.statusText = args.statusText
+ this.collection = args.collection
+ this.url = args.url
+ this.body = args.body
+ }
+}
+
function buildUrl(collection: string, q: DirectusQuery = {}): string {
const url = new URL(`${DIRECTUS_URL}/items/${collection}`)
for (const field of q.fields ?? []) url.searchParams.append('fields[]', field)
@@ -31,13 +55,32 @@ function buildUrl(collection: string, q: DirectusQuery = {}): string {
return url.toString()
}
+async function fetchOrThrow(collection: string, url: string): Promise {
+ const res = await fetch(url)
+ if (!res.ok) {
+ const body = await res.text().catch(() => '')
+ let parsed: unknown = body
+ try {
+ parsed = body ? JSON.parse(body) : ''
+ } catch {
+ // body wasn't JSON; keep raw text
+ }
+ throw new DirectusError({
+ status: res.status,
+ statusText: res.statusText,
+ collection,
+ url,
+ body: parsed,
+ })
+ }
+ return res.json()
+}
+
export async function directusList(
collection: string,
q: DirectusQuery = {},
): Promise> {
- const res = await fetch(buildUrl(collection, q))
- if (!res.ok) throw new Error(`Directus error ${res.status} on /items/${collection}`)
- return (await res.json()) as DirectusListResponse
+ return (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusListResponse
}
export async function directusOne(collection: string, q: DirectusQuery = {}): Promise {
@@ -46,11 +89,7 @@ export async function directusOne(collection: string, q: DirectusQuery = {}):
}
export async function directusSingleton(collection: string, q: DirectusQuery = {}): Promise {
- const url = new URL(`${DIRECTUS_URL}/items/${collection}`)
- for (const field of q.fields ?? []) url.searchParams.append('fields[]', field)
- const res = await fetch(url.toString())
- if (!res.ok) throw new Error(`Directus error ${res.status} on /items/${collection}`)
- const { data } = (await res.json()) as DirectusItemResponse
+ const { data } = (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusItemResponse
return data
}
diff --git a/frontend/src/routes/vendors/index.tsx b/frontend/src/routes/vendors/index.tsx
index 8a147a9..bca9648 100644
--- a/frontend/src/routes/vendors/index.tsx
+++ b/frontend/src/routes/vendors/index.tsx
@@ -1,28 +1,20 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
-import { createFileRoute, Link } from '@tanstack/react-router'
-import { Check, Filter, FilterX, LayoutGrid, List, Loader2, Search, SmilePlus, X } from 'lucide-react'
+import { createFileRoute } from '@tanstack/react-router'
+import { SmilePlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { Pagination } from '~/components/pagination'
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
-import { Button } from '~/components/ui/button'
-import { Card, CardContent } from '~/components/ui/card'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '~/components/ui/dropdown-menu'
-import { Input } from '~/components/ui/input'
-import { Skeleton } from '~/components/ui/skeleton'
+import { PerPageSelector } from '~/components/vendors/per-page-selector'
+import type { VendorLayout } from '~/components/vendors/types'
+import { VendorGrid } from '~/components/vendors/vendor-grid'
+import { VendorList } from '~/components/vendors/vendor-list'
+import { VendorToolbar } from '~/components/vendors/vendor-toolbar'
import { useDebouncedCallback } from '~/hooks/use-debounced-callback'
import { useLocalStorage } from '~/hooks/use-local-storage'
-import { assetUrl } from '~/lib/directus'
import { categoriesQuery, vendorsListQuery } from '~/lib/queries'
-import { cn } from '~/lib/utils'
type Search = {
page?: number
@@ -43,15 +35,6 @@ export const Route = createFileRoute('/vendors/')({
component: VendorsPage,
})
-type Layout = 'GRID' | 'LIST'
-
-function truncateDescription(desc: string | null | undefined): string {
- if (!desc) return ''
- const head = desc.substring(0, 80)
- const end = head.lastIndexOf(' ')
- return desc.substring(0, end > 0 ? end : head.length) + ' …'
-}
-
function VendorsPage() {
const { page = 1, q = '', category = '' } = Route.useSearch()
const navigate = Route.useNavigate()
@@ -59,7 +42,7 @@ function VendorsPage() {
const { data: categories } = useSuspenseQuery(categoriesQuery)
const [perPage, setPerPage] = useLocalStorage('perPage', 12)
- const [layout, setLayout] = useLocalStorage('layout', 'GRID')
+ const [layout, setLayout] = useLocalStorage('layout', 'GRID')
const [searchInput, setSearchInput] = useState(q)
useEffect(() => {
@@ -86,6 +69,7 @@ function VendorsPage() {
}
}, [response, page, lastPage, navigate])
+ const isLoading = isFetching && !response
const isEmpty = !isFetching && filterCount === 0
return (
@@ -94,91 +78,25 @@ function VendorsPage() {
Vendors
-
-
Vendors
-
- {isFetching && }
-
-
-
-
- {
- const v = e.target.value.toLowerCase()
- setSearchInput(v)
- pushSearch(v)
- }}
- />
- {searchInput && (
-
- )}
-
-
-
-
-
-
-
- {categories.map((cat) => (
-
- navigate({ search: (s) => ({ ...s, category: cat.slug, page: undefined }) })
- }
- >
- {category === cat.slug && }
- {cat.name}
-
- ))}
-
- navigate({ search: (s) => ({ ...s, category: undefined, page: undefined }) })}
- >
- Clear
-
-
-
-
-
-
-
-
-
-
+ {
+ setSearchInput(v)
+ pushSearch(v)
+ }}
+ onSearchClear={() => {
+ setSearchInput('')
+ pushSearch('')
+ }}
+ isFetching={isFetching}
+ categories={categories}
+ category={category}
+ onCategoryChange={(slug) =>
+ navigate({ search: (s) => ({ ...s, category: slug, page: undefined }) })
+ }
+ layout={layout}
+ onLayoutChange={setLayout}
+ />
{error && (
@@ -187,84 +105,13 @@ function VendorsPage() {
)}
{layout === 'GRID' ? (
-
- {isFetching && !response
- ? Array.from({ length: perPage }).map((_, k) => (
-
-
-
-
-
-
-
-
- ))
- : vendors.map((v) => (
-
-
-
-
- {v.name}
-
- {truncateDescription(v.description)}
-
-
- ))}
-
+
) : (
-
-
-
- {isFetching && !response
- ? Array.from({ length: perPage }).map((_, k) => (
-
- |
-
- |
-
-
- |
-
-
- |
-
- ))
- : vendors.map((v) => (
-
-
-
- |
-
-
- {v.name}
-
- |
-
- {truncateDescription(v.description)}
- |
-
- ))}
-
-
-
+
)}
{isEmpty && (
-
+
No results found.
@@ -287,28 +134,7 @@ function VendorsPage() {
page: p,
})}
/>
-
-
Results per page:
-
- {[12, 24, 48].map((n, i) => (
-
- ))}
-
-
+
>
)}