Add TanStack Router, TanStack Query, Directus client, entity types

This commit is contained in:
2026-04-23 21:09:15 +04:00
parent 46001192be
commit d50a22a7b8
9 changed files with 327 additions and 15 deletions

View 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
View 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
View 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
View 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[]
}