From b4815313010054972d9dcbeb2fc513fad03833b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Markovi=C4=87?= Date: Thu, 23 Apr 2026 21:52:15 +0400 Subject: [PATCH] Extract vendor page subcomponents; structured Directus errors 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. --- .../components/vendors/per-page-selector.tsx | 37 +++ frontend/src/components/vendors/types.ts | 8 + .../src/components/vendors/vendor-grid.tsx | 53 ++++ .../src/components/vendors/vendor-list.tsx | 57 +++++ .../src/components/vendors/vendor-toolbar.tsx | 113 +++++++++ frontend/src/hooks/use-local-storage.ts | 7 +- frontend/src/lib/directus.ts | 55 +++- frontend/src/routes/vendors/index.tsx | 238 +++--------------- 8 files changed, 351 insertions(+), 217 deletions(-) create mode 100644 frontend/src/components/vendors/per-page-selector.tsx create mode 100644 frontend/src/components/vendors/types.ts create mode 100644 frontend/src/components/vendors/vendor-grid.tsx create mode 100644 frontend/src/components/vendors/vendor-list.tsx create mode 100644 frontend/src/components/vendors/vendor-toolbar.tsx 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} + + + {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} + + + {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} - - - {v.name} - -

{truncateDescription(v.description)}

-
-
- ))} -
+ ) : ( -
- - - {isFetching && !response - ? Array.from({ length: perPage }).map((_, k) => ( - - - - - - )) - : vendors.map((v) => ( - - - - - - ))} - -
- - - - - -
- {v.name} - - - {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) => ( - - ))} -
-
+ )}