Compare commits

...

6 Commits

Author SHA1 Message Date
33f52aad6c Allow prod host through vite preview 2026-04-23 22:41:06 +04:00
da8a7f17a4 Make docker startup work on a cold cache
Frontend: tsc was running before vite, but the routeTree.gen.ts that
the routes import is generated by the Vite plugin at startup — fresh
container had no cache, so tsc failed before vite ever ran. Reverse
the order: vite first (generates routeTree), then tsc --noEmit
validates types.

Backend: run `directus database migrate:latest` before start; the
deployed SQLite is on an old schema (Directus 10.x) and `directus
start` aborts on the missing `searchable` column.

Both services: set CI=true so pnpm doesn't prompt for `node_modules`
purge on a non-TTY container, and use `sh -ec` so a failed step halts
the script instead of falling through to `pnpm run start`.
2026-04-23 22:23:26 +04:00
2fe67960a1 Remove stale per-package lockfiles
Now ignored via .gitignore; the workspace-root pnpm-lock.yaml is the
single source of truth. These files were never tracked in git and
caused --frozen-lockfile to reject installs in the docker containers.
2026-04-23 21:58:59 +04:00
fc59e7e133 Run docker installs from the workspace root
Mounting only ./backend and ./frontend into /app made pnpm look for a
per-package lockfile (stale) and miss the workspace-root one entirely,
so --frozen-lockfile failed and root pnpm.onlyBuiltDependencies didn't
apply. Mount the whole repo at /repo, install with --filter from root,
and use the canonical workspace lockfile. Drop the per-package
onlyBuiltDependencies overrides — pnpm warns they're ignored outside
the workspace root.

Pin node:22-bookworm: node:lts now resolves to Node 24, for which
isolated-vm@5.0.3 has no prebuild and won't compile (V8 headers
require C++20).

Gitignore stray per-package lockfiles so they don't drift again.
2026-04-23 21:57:37 +04:00
39460df61d Fix prod docker build: native modules and frontend devDeps
Per-package onlyBuiltDependencies so pnpm 10 actually compiles native
modules (isolated-vm, argon2, sqlite3, sharp) — root workspace config
isn't visible when compose mounts a single package into /app.

Frontend container now installs with devDeps (so tsc/vite exist),
prunes after build, and gains a `start` script that serves the build
via vite preview on 0.0.0.0:3000.

Drop obsolete compose `version` key.
2026-04-23 21:52:22 +04:00
b481531301 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.
2026-04-23 21:52:15 +04:00
14 changed files with 380 additions and 18155 deletions

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ node_modules/
package-lock.json
yarn.lock
# pnpm: only the workspace-root lockfile is authoritative
backend/pnpm-lock.yaml
frontend/pnpm-lock.yaml
# Mac
.DS_Store

14825
backend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,61 @@
version: '3'
networks:
main:
external: true
services:
backend:
image: node:lts-bookworm
image: node:22-bookworm
container_name: pca_pijac_backend
restart: unless-stopped
env_file: ./backend/.env.production
working_dir: /repo/backend
networks:
- main
volumes:
- ./backend:/app
- .:/repo
- ./data:/data
labels:
caddy: admin.pca-pijac.dev.civokram.com
caddy.reverse_proxy: '{{upstreams 8055}}'
environment:
CI: "true"
command:
- sh
- -c
- -ec
- |
npm i -g pnpm
cd /app
pnpm install --production
cd /repo
pnpm install --filter ./backend... --frozen-lockfile --prod
cd /repo/backend
rm -f .env
pnpm exec directus database migrate:latest
pnpm run start
frontend:
image: node:lts-bookworm
image: node:22-bookworm
container_name: pca_pijac_frontend
restart: unless-stopped
env_file: ./frontend/.env.production
working_dir: /repo/frontend
depends_on:
- backend
networks:
- main
volumes:
- ./frontend:/app
- .:/repo
labels:
caddy: pca-pijac.dev.civokram.com
caddy.reverse_proxy: '{{upstreams 3000}}'
environment:
CI: "true"
command:
- sh
- -c
- -ec
- |
npm i -g pnpm
cd /app
pnpm install --production
cd /repo
pnpm install --filter ./frontend... --frozen-lockfile
cd /repo/frontend
sleep 10
rm -f .env
pnpm run build

View File

@@ -10,8 +10,9 @@
"packageManager": "pnpm@10.33.2",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "vite build && tsc -b --noEmit",
"preview": "vite preview",
"start": "vite preview --host 0.0.0.0 --port 3000",
"typecheck": "tsc -b --noEmit",
"format": "prettier --ignore-unknown --write .",
"format:check": "prettier --check ."

3100
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 (
<div className="mt-5 flex items-center justify-center gap-2">
<span className="text-sm">Results per page:</span>
<div className="inline-flex rounded-md border">
{options.map((n, i) => (
<Button
key={n}
variant={value === n ? 'secondary' : 'ghost'}
size="sm"
disabled={value === n}
onClick={() => onChange(n)}
className={cn(
'rounded-none',
i === 0 && 'rounded-l-md',
i === options.length - 1 && 'rounded-r-md',
i > 0 && 'border-l',
)}
>
{n}
</Button>
))}
</div>
</div>
)
}

View File

@@ -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) + ' …'
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{isLoading
? Array.from({ length: perPage }).map((_, k) => (
<Card key={k}>
<Skeleton className="h-40 rounded-none" />
<CardContent className="pt-4">
<Skeleton className="mb-2 h-6" />
<Skeleton className="mb-1 h-4" />
<Skeleton className="h-4" />
</CardContent>
</Card>
))
: vendors.map((v) => (
<Card key={v.id} className="group relative hover:border-primary/40">
<img
src={assetUrl(v.logo, 'logo-card')}
alt={v.name}
width={250}
height={150}
className="h-40 w-full rounded-t-lg bg-white object-contain"
/>
<CardContent className="pt-4">
<Link
to="/vendors/$slug"
params={{ slug: v.slug }}
className="mb-2 block text-base font-extrabold after:absolute after:inset-0"
>
{v.name}
</Link>
<p className="text-sm text-muted-foreground">{truncateDescription(v.description)}</p>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -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 (
<div className="overflow-hidden rounded-md border">
<table className="w-full caption-bottom text-sm">
<tbody>
{isLoading
? Array.from({ length: perPage }).map((_, k) => (
<tr key={k} className="border-b">
<td className="w-[10%] p-3">
<Skeleton className="h-12 w-12" />
</td>
<td className="w-[30%] p-3">
<Skeleton className="h-6" />
</td>
<td className="w-[60%] p-3">
<Skeleton className="h-6" />
</td>
</tr>
))
: vendors.map((v) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-accent/50">
<td className="w-[10%] p-3 align-middle">
<img
src={assetUrl(v.logo, 'logo-card')}
alt={v.name}
className="h-12 w-12 rounded-md bg-white object-contain"
/>
</td>
<td className="w-[30%] p-3 align-middle font-medium">
<Link to="/vendors/$slug" params={{ slug: v.slug }} className="hover:underline">
{v.name}
</Link>
</td>
<td className="w-[60%] p-3 align-middle text-muted-foreground">
{truncateDescription(v.description)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -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 (
<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>
)
}

View File

@@ -6,7 +6,8 @@ export function useLocalStorage<T>(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<T>(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])

View File

@@ -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<unknown> {
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<T>(
collection: string,
q: DirectusQuery = {},
): Promise<DirectusListResponse<T>> {
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<T>
return (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusListResponse<T>
}
export async function directusOne<T>(collection: string, q: DirectusQuery = {}): Promise<T | undefined> {
@@ -46,11 +89,7 @@ export async function directusOne<T>(collection: string, q: DirectusQuery = {}):
}
export async function directusSingleton<T>(collection: string, q: DirectusQuery = {}): Promise<T> {
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<T>
const { data } = (await fetchOrThrow(collection, buildUrl(collection, q))) as DirectusItemResponse<T>
return data
}

View File

@@ -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<number>('perPage', 12)
const [layout, setLayout] = useLocalStorage<Layout>('layout', 'GRID')
const [layout, setLayout] = useLocalStorage<VendorLayout>('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() {
<title>Vendors</title>
</Helmet>
<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) => {
const v = e.target.value.toLowerCase()
setSearchInput(v)
pushSearch(v)
}}
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('')
pushSearch('')
}}
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={() =>
navigate({ search: (s) => ({ ...s, category: cat.slug, page: undefined }) })
}
>
{category === cat.slug && <Check className="h-4 w-4" />}
{cat.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => navigate({ search: (s) => ({ ...s, category: undefined, page: 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={() => setLayout('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={() => setLayout('LIST')}
aria-label="List layout"
disabled={layout === 'LIST'}
>
<List />
</Button>
</div>
</div>
</div>
<VendorToolbar
searchInput={searchInput}
onSearchInput={(v) => {
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 && (
<Alert variant="destructive" className="my-3">
@@ -187,84 +105,13 @@ function VendorsPage() {
)}
{layout === 'GRID' ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{isFetching && !response
? Array.from({ length: perPage }).map((_, k) => (
<Card key={k}>
<Skeleton className="h-40 rounded-none" />
<CardContent className="pt-4">
<Skeleton className="mb-2 h-6" />
<Skeleton className="mb-1 h-4" />
<Skeleton className="h-4" />
</CardContent>
</Card>
))
: vendors.map((v) => (
<Card key={v.id} className="group relative hover:border-primary/40">
<img
src={assetUrl(v.logo, 'logo-card')}
alt={v.name}
width={250}
height={150}
className="h-40 w-full rounded-t-lg bg-white object-contain"
/>
<CardContent className="pt-4">
<Link
to="/vendors/$slug"
params={{ slug: v.slug }}
className="mb-2 block text-base font-extrabold after:absolute after:inset-0"
>
{v.name}
</Link>
<p className="text-sm text-muted-foreground">{truncateDescription(v.description)}</p>
</CardContent>
</Card>
))}
</div>
<VendorGrid vendors={vendors} isLoading={isLoading} perPage={perPage} />
) : (
<div className="overflow-hidden rounded-md border">
<table className="w-full caption-bottom text-sm">
<tbody>
{isFetching && !response
? Array.from({ length: perPage }).map((_, k) => (
<tr key={k} className="border-b">
<td className="w-[10%] p-3">
<Skeleton className="h-12 w-12" />
</td>
<td className="w-[30%] p-3">
<Skeleton className="h-6" />
</td>
<td className="w-[60%] p-3">
<Skeleton className="h-6" />
</td>
</tr>
))
: vendors.map((v) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-accent/50">
<td className="w-[10%] p-3 align-middle">
<img
src={assetUrl(v.logo, 'logo-card')}
alt={v.name}
className="h-12 w-12 rounded-md bg-white object-contain"
/>
</td>
<td className="w-[30%] p-3 align-middle font-medium">
<Link to="/vendors/$slug" params={{ slug: v.slug }} className="hover:underline">
{v.name}
</Link>
</td>
<td className="w-[60%] p-3 align-middle text-muted-foreground">
{truncateDescription(v.description)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<VendorList vendors={vendors} isLoading={isLoading} perPage={perPage} />
)}
{isEmpty && (
<Alert variant="info" className={cn('my-3 rounded-md py-10')}>
<Alert variant="info" className="my-3 rounded-md py-10">
<div className="flex flex-col items-center gap-2 text-center">
<SmilePlus className="h-8 w-8" />
<AlertTitle className="mt-4 text-lg">No results found.</AlertTitle>
@@ -287,28 +134,7 @@ function VendorsPage() {
page: p,
})}
/>
<div className="mt-5 flex items-center justify-center gap-2">
<span className="text-sm">Results per page:</span>
<div className="inline-flex rounded-md border">
{[12, 24, 48].map((n, i) => (
<Button
key={n}
variant={perPage === n ? 'secondary' : 'ghost'}
size="sm"
disabled={perPage === n}
onClick={() => setPerPage(n)}
className={cn(
'rounded-none',
i === 0 && 'rounded-l-md',
i === 2 && 'rounded-r-md',
i > 0 && 'border-l',
)}
>
{n}
</Button>
))}
</div>
</div>
<PerPageSelector value={perPage} onChange={setPerPage} />
</>
)}
</section>

View File

@@ -24,6 +24,10 @@ export default defineConfig({
server: {
port: 3000,
},
preview: {
port: 3000,
allowedHosts: ['pca-pijac.dev.civokram.com'],
},
build: {
outDir: 'node_modules/.cache/dist',
emptyOutDir: true,