Add TanStack Router, TanStack Query, Directus client, entity types
This commit is contained in:
@@ -1,13 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background text-foreground p-8">
|
|
||||||
<h1 className="text-3xl font-bold mb-2">PCA Pijac</h1>
|
|
||||||
<p className="text-muted-foreground mb-4">
|
|
||||||
Vite + React + TypeScript + TanStack Router + TanStack Query + Tailwind + shadcn/ui
|
|
||||||
</p>
|
|
||||||
<Button>Hello, shadcn</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
50
frontend/src/lib/directus.ts
Normal file
50
frontend/src/lib/directus.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { DIRECTUS_URL } from './env'
|
||||||
|
|
||||||
|
export type DirectusListResponse<T> = {
|
||||||
|
data: T[]
|
||||||
|
meta?: { filter_count?: number; total_count?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectusItemResponse<T> = {
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectusFilter = Record<string, unknown>
|
||||||
|
|
||||||
|
export type DirectusQuery = {
|
||||||
|
fields?: string[]
|
||||||
|
filter?: DirectusFilter
|
||||||
|
sort?: string
|
||||||
|
limit?: number
|
||||||
|
page?: number
|
||||||
|
meta?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
for (const m of q.meta ?? []) url.searchParams.append('meta[]', m)
|
||||||
|
if (q.filter) url.searchParams.append('filter', JSON.stringify(q.filter))
|
||||||
|
if (q.sort) url.searchParams.append('sort', q.sort)
|
||||||
|
if (q.limit !== undefined) url.searchParams.append('limit', String(q.limit))
|
||||||
|
if (q.page !== undefined) url.searchParams.append('page', String(q.page))
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function directusOne<T>(collection: string, q: DirectusQuery = {}): Promise<T | undefined> {
|
||||||
|
const { data } = await directusList<T>(collection, { ...q, limit: 1 })
|
||||||
|
return data[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetUrl(fileId: string | null | undefined, key?: string): string {
|
||||||
|
if (!fileId) return ''
|
||||||
|
const url = new URL(`${DIRECTUS_URL}/assets/${fileId}`)
|
||||||
|
if (key) url.searchParams.append('key', key)
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
6
frontend/src/lib/env.ts
Normal file
6
frontend/src/lib/env.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const DIRECTUS_URL = import.meta.env.VITE_DIRECTUS_API_URL as string
|
||||||
|
export const GLOBALS_ID = import.meta.env.VITE_GLOBALS_ID as string
|
||||||
|
|
||||||
|
if (!DIRECTUS_URL) {
|
||||||
|
throw new Error('VITE_DIRECTUS_API_URL is not set')
|
||||||
|
}
|
||||||
118
frontend/src/lib/queries.ts
Normal file
118
frontend/src/lib/queries.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { directusList, directusOne } from './directus'
|
||||||
|
import { GLOBALS_ID } from './env'
|
||||||
|
import type { Category, Globals, Menu, MenuRaw, PageEntity, Vendor, VendorListItem } from './types'
|
||||||
|
|
||||||
|
export const globalsQuery = queryOptions({
|
||||||
|
queryKey: ['globals'],
|
||||||
|
queryFn: async (): Promise<Globals> => {
|
||||||
|
const item = await directusOne<Globals>('globals', { fields: ['*'] })
|
||||||
|
if (!item) throw new Error('globals not found')
|
||||||
|
return item
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// globals is a singleton; if the ID ever changes, we could filter explicitly.
|
||||||
|
export { GLOBALS_ID }
|
||||||
|
|
||||||
|
export const menusQuery = queryOptions({
|
||||||
|
queryKey: ['menus'],
|
||||||
|
queryFn: async (): Promise<Menu[]> => {
|
||||||
|
const { data } = await directusList<MenuRaw>('menus', {
|
||||||
|
fields: [
|
||||||
|
'*',
|
||||||
|
'menus_menu_items.sort',
|
||||||
|
'menus_menu_items.menu_items_id.label',
|
||||||
|
'menus_menu_items.menu_items_id.url',
|
||||||
|
'menus_menu_items.menu_items_id.sort',
|
||||||
|
],
|
||||||
|
limit: -1,
|
||||||
|
})
|
||||||
|
return data.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
items: [...m.menus_menu_items].sort((a, b) => a.sort - b.sort).map((mm) => mm.menu_items_id),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const categoriesQuery = queryOptions({
|
||||||
|
queryKey: ['categories', 'roots'],
|
||||||
|
queryFn: async (): Promise<Category[]> => {
|
||||||
|
const { data } = await directusList<Category>('categories', {
|
||||||
|
fields: ['slug', 'name', 'subcategories.slug', 'subcategories.name'],
|
||||||
|
sort: 'name',
|
||||||
|
limit: -1,
|
||||||
|
filter: { parent_id: { _null: true } },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pageBySlugQuery = (slug: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['page', slug],
|
||||||
|
queryFn: async (): Promise<PageEntity | undefined> => {
|
||||||
|
return directusOne<PageEntity>('pages', {
|
||||||
|
fields: ['slug', 'title', 'content'],
|
||||||
|
filter: { slug: { _eq: slug } },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type VendorsQueryParams = {
|
||||||
|
page: number
|
||||||
|
perPage: number
|
||||||
|
search: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vendorsListQuery = ({ page, perPage, search, category }: VendorsQueryParams) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['vendors', { page, perPage, search, category }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const filter =
|
||||||
|
search || category
|
||||||
|
? {
|
||||||
|
_and: [
|
||||||
|
category ? { categories: { categories_id: { slug: { _eq: category } } } } : {},
|
||||||
|
search
|
||||||
|
? {
|
||||||
|
_or: (['name', 'description', 'long_description', 'city'] as const).map((f) => ({
|
||||||
|
[f]: { _contains: search },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return directusList<VendorListItem>('vendors', {
|
||||||
|
fields: ['*'],
|
||||||
|
limit: perPage,
|
||||||
|
page,
|
||||||
|
sort: 'name',
|
||||||
|
meta: ['filter_count'],
|
||||||
|
filter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const vendorBySlugQuery = (slug: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['vendor', slug],
|
||||||
|
queryFn: async (): Promise<Vendor | undefined> => {
|
||||||
|
return directusOne<Vendor>('vendors', {
|
||||||
|
fields: [
|
||||||
|
'*',
|
||||||
|
'categories.categories_id.slug',
|
||||||
|
'categories.categories_id.name',
|
||||||
|
'categories.categories_id.parent_id',
|
||||||
|
],
|
||||||
|
filter: { slug: { _eq: slug } },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
65
frontend/src/lib/types.ts
Normal file
65
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export type Globals = {
|
||||||
|
id: string
|
||||||
|
site_name: string
|
||||||
|
meta_title: string
|
||||||
|
meta_description: string
|
||||||
|
copyright: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuItem = {
|
||||||
|
label: string
|
||||||
|
url: string | null
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuRaw = {
|
||||||
|
id: string
|
||||||
|
menus_menu_items: Array<{
|
||||||
|
sort: number
|
||||||
|
menu_items_id: MenuItem
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Menu = {
|
||||||
|
id: string
|
||||||
|
items: MenuItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PageEntity = {
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Category = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
parent_id: string | null
|
||||||
|
subcategories?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VendorCategoryJoin = {
|
||||||
|
categories_id: Category
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VendorListItem = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
logo: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Vendor = VendorListItem & {
|
||||||
|
long_description: string | null
|
||||||
|
address_line_1: string | null
|
||||||
|
address_line_2: string | null
|
||||||
|
city: string | null
|
||||||
|
state: string | null
|
||||||
|
country: string | null
|
||||||
|
website: string | null
|
||||||
|
facebook: string | null
|
||||||
|
linkedin: string | null
|
||||||
|
twitter: string | null
|
||||||
|
categories: VendorCategoryJoin[]
|
||||||
|
}
|
||||||
@@ -1,10 +1,39 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
import { routeTree } from '@generated/tanstack-router/routeTree.gen'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: { queryClient },
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<HelmetProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</HelmetProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
37
frontend/src/routes/__root.tsx
Normal file
37
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
|
||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { globalsQuery, menusQuery } from '@/lib/queries'
|
||||||
|
|
||||||
|
const TanStackRouterDevtools =
|
||||||
|
import.meta.env.PROD
|
||||||
|
? () => null
|
||||||
|
: lazy(() =>
|
||||||
|
import('@tanstack/react-router-devtools').then((m) => ({ default: m.TanStackRouterDevtools })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ReactQueryDevtools =
|
||||||
|
import.meta.env.PROD
|
||||||
|
? () => null
|
||||||
|
: lazy(() =>
|
||||||
|
import('@tanstack/react-query-devtools').then((m) => ({ default: m.ReactQueryDevtools })),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await Promise.all([queryClient.ensureQueryData(globalsQuery), queryClient.ensureQueryData(menusQuery)])
|
||||||
|
},
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<Suspense>
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
<ReactQueryDevtools buttonPosition="bottom-left" />
|
||||||
|
</Suspense>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/src/routes/index.tsx
Normal file
10
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
component: () => (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-3xl font-bold">Home</h1>
|
||||||
|
<p className="text-muted-foreground">Routing placeholder — CMS page will render here.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_DIRECTUS_API_URL: string
|
||||||
|
readonly VITE_GLOBALS_ID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user