Compare commits
22 Commits
3a2a320e7c
...
f5f167549f
| Author | SHA1 | Date | |
|---|---|---|---|
| f5f167549f | |||
| 222669acf0 | |||
| d74710aaa1 | |||
| 2642d295d4 | |||
| ecf1afe6e4 | |||
| 6f8ced6f2f | |||
| 0a5f2ba391 | |||
| e89a8175e2 | |||
| c8ee8a3ec2 | |||
| 5b0b0c3430 | |||
| 73a0d24c4f | |||
| 8dd618ce88 | |||
| d68d780a00 | |||
| 128f9d80fb | |||
| d5d666c56a | |||
| d50a22a7b8 | |||
| 46001192be | |||
| abef71e4ab | |||
| 63ac2df4b5 | |||
| a3ec5b78c6 | |||
| 49b081c00b | |||
| 8ca9ea157c |
93
Makefile
Normal file
93
Makefile
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.PHONY: help install rebuild init dev dev-backend dev-frontend build start \
|
||||||
|
typecheck format db-import db-dump admin clean clean-db \
|
||||||
|
docker-up docker-down docker-logs
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "PCA Pijac — available targets:"
|
||||||
|
@echo ""
|
||||||
|
@echo " make init Install deps, rebuild native modules, import DB"
|
||||||
|
@echo " make install Install deps in root, backend, frontend"
|
||||||
|
@echo " make rebuild Rebuild native modules (argon2, sqlite3, sharp)"
|
||||||
|
@echo " make dev Run backend + frontend in dev (parallel)"
|
||||||
|
@echo " make dev-backend Run Directus backend only (nodemon)"
|
||||||
|
@echo " make dev-frontend Run Vite frontend only"
|
||||||
|
@echo " make build Build frontend for production"
|
||||||
|
@echo " make start Start backend + frontend in production mode"
|
||||||
|
@echo " make typecheck Typecheck frontend (tsc -b --noEmit)"
|
||||||
|
@echo " make format Prettier format frontend"
|
||||||
|
@echo ""
|
||||||
|
@echo " make db-import Reset data/data.db from schema/dump.sql"
|
||||||
|
@echo " make db-dump Dump current DB to schema/dump.sql"
|
||||||
|
@echo " make admin EMAIL=x@y PASSWORD=secret"
|
||||||
|
@echo " Create a Directus admin user"
|
||||||
|
@echo ""
|
||||||
|
@echo " make docker-up docker compose up -d"
|
||||||
|
@echo " make docker-down docker compose down"
|
||||||
|
@echo " make docker-logs docker compose logs -f"
|
||||||
|
@echo ""
|
||||||
|
@echo " make clean Remove node_modules and .next"
|
||||||
|
@echo " make clean-db Remove data/data.db"
|
||||||
|
|
||||||
|
install:
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
rebuild:
|
||||||
|
pnpm install --force
|
||||||
|
|
||||||
|
init: install rebuild db-import
|
||||||
|
@echo "Init complete. Run 'make admin EMAIL=... PASSWORD=...' to create an admin user."
|
||||||
|
|
||||||
|
dev:
|
||||||
|
pnpm run dev
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && pnpm run dev
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && pnpm run dev
|
||||||
|
|
||||||
|
build:
|
||||||
|
cd frontend && pnpm run build
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
cd frontend && pnpm run typecheck
|
||||||
|
|
||||||
|
format:
|
||||||
|
cd frontend && pnpm run format
|
||||||
|
|
||||||
|
start:
|
||||||
|
cd backend && pnpm run start & \
|
||||||
|
cd frontend && pnpm run preview; \
|
||||||
|
wait
|
||||||
|
|
||||||
|
db-import:
|
||||||
|
cd backend && pnpm run db:import
|
||||||
|
|
||||||
|
db-dump:
|
||||||
|
cd backend && pnpm run db:dump
|
||||||
|
|
||||||
|
admin:
|
||||||
|
@if [ -z "$(EMAIL)" ] || [ -z "$(PASSWORD)" ]; then \
|
||||||
|
echo "Usage: make admin EMAIL=you@example.com PASSWORD=secret"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
cd backend && pnpm directus users create \
|
||||||
|
--email "$(EMAIL)" \
|
||||||
|
--password "$(PASSWORD)" \
|
||||||
|
--role 067808bd-7c13-40be-8c11-9ba654b79465
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf node_modules backend/node_modules frontend/node_modules
|
||||||
|
|
||||||
|
clean-db:
|
||||||
|
rm -f data/data.db
|
||||||
@@ -23,7 +23,7 @@ PORT=8055
|
|||||||
# The URL where your API can be reached on the web. It is also used for things like OAuth redirects,
|
# The URL where your API can be reached on the web. It is also used for things like OAuth redirects,
|
||||||
# forgot-password emails, and logos that needs to be publicly available on the internet. ["/"]
|
# forgot-password emails, and logos that needs to be publicly available on the internet. ["/"]
|
||||||
# PUBLIC_URL=http://localhost:8055
|
# PUBLIC_URL=http://localhost:8055
|
||||||
PUBLIC_URL=https://admin.dev.civokram.com
|
PUBLIC_URL=https://admin.pca-pijac.dev.civokram.com
|
||||||
|
|
||||||
# What level of detail to log. [info]
|
# What level of detail to log. [info]
|
||||||
# "fatal", "error", "warn", "info", "debug", "trace", "silent"
|
# "fatal", "error", "warn", "info", "debug", "trace", "silent"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22",
|
||||||
|
"pnpm": ">=10"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"db:dump": "sqlite3 ../data/data.db < ../schema/clean_and_dump.sql > ../schema/dump.sql",
|
"db:dump": "sqlite3 ../data/data.db < ../schema/clean_and_dump.sql > ../schema/dump.sql",
|
||||||
"db:import": "rm -f ../data/data.db && sqlite3 ../data/data.db < ../schema/dump.sql",
|
"db:import": "rm -f ../data/data.db && sqlite3 ../data/data.db < ../schema/dump.sql",
|
||||||
@@ -7,11 +12,11 @@
|
|||||||
"start": "directus start"
|
"start": "directus start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"directus": "10.4.2",
|
"directus": "11.17.3",
|
||||||
"sqlite3": "5.1.6"
|
"sqlite3": "6.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "3.0.1",
|
"nodemon": "3.1.14",
|
||||||
"uuid": "9.0.0"
|
"uuid": "14.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19333
backend/pnpm-lock.yaml
generated
19333
backend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,2 @@
|
|||||||
|
VITE_DIRECTUS_API_URL=http://localhost:8055
|
||||||
# The URL where your API can be reached on the web.
|
VITE_GLOBALS_ID=4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45
|
||||||
NEXT_PUBLIC_DIRECTUS_API_URL="https://admin.pca-pijac.dev.civokram.com"
|
|
||||||
NEXT_PUBLIC_GLOBALS_ID="4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45"
|
|
||||||
|
|||||||
@@ -1,4 +1,2 @@
|
|||||||
|
VITE_DIRECTUS_API_URL=https://admin.pca-pijac.dev.civokram.com
|
||||||
# The URL where your API can be reached on the web.
|
VITE_GLOBALS_ID=4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45
|
||||||
NEXT_PUBLIC_DIRECTUS_API_URL=https://admin.pca-pijac.dev.civokram.com
|
|
||||||
NEXT_PUBLIC_GLOBALS_ID=4f8d9e66-ec95-4bdd-a5e7-34df8ea68a45
|
|
||||||
|
|||||||
37
frontend/.gitignore
vendored
37
frontend/.gitignore
vendored
@@ -1,35 +1,4 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
node_modules
|
||||||
|
*.local
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
.next
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|||||||
3
frontend/.prettierignore
Normal file
3
frontend/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
8
frontend/.prettierrc.json
Normal file
8
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 110,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"plugins": ["prettier-plugin-organize-imports"]
|
||||||
|
}
|
||||||
19
frontend/components.json
Normal file
19
frontend/components.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "~/components",
|
||||||
|
"utils": "~/lib/utils",
|
||||||
|
"ui": "~/components/ui",
|
||||||
|
"lib": "~/lib",
|
||||||
|
"hooks": "~/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Link } from '@chakra-ui/next-js'
|
|
||||||
import { Box, Flex, Stack, Text, useColorModeValue } from '@chakra-ui/react'
|
|
||||||
|
|
||||||
export default function Footer({ menu, globals }) {
|
|
||||||
return (
|
|
||||||
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4} mt="8">
|
|
||||||
<Flex h={16} alignItems={'center'} justifyContent="space-between">
|
|
||||||
<FooterMenu items={menu.items} />
|
|
||||||
<Text>{globals.copyright}</Text>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FooterMenu = ({ items }) => {
|
|
||||||
const linkColor = useColorModeValue('gray.600', 'gray.200')
|
|
||||||
const linkHoverColor = useColorModeValue('gray.800', 'white')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction={'row'} spacing={4} pl="4">
|
|
||||||
{items.map((navItem) => (
|
|
||||||
<Box key={navItem.label}>
|
|
||||||
<Link
|
|
||||||
p={2}
|
|
||||||
href={navItem.url ?? '#'}
|
|
||||||
fontSize={'sm'}
|
|
||||||
fontWeight={500}
|
|
||||||
color={linkColor}
|
|
||||||
_hover={{
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: linkHoverColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{navItem.label}
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { MoonIcon, SunIcon } from '@chakra-ui/icons'
|
|
||||||
import { Box, Button, Flex, Heading, Spacer, Stack, useColorMode, useColorModeValue } from '@chakra-ui/react'
|
|
||||||
import { Link } from '@chakra-ui/next-js'
|
|
||||||
|
|
||||||
export default function Header({ menu, globals }) {
|
|
||||||
const { colorMode, toggleColorMode } = useColorMode()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box bg={useColorModeValue('gray.100', 'gray.900')} px={4}>
|
|
||||||
<Flex h={16} alignItems={'center'}>
|
|
||||||
<Heading size="md">{globals.site_name}</Heading>
|
|
||||||
|
|
||||||
<MainMenu items={menu.items} />
|
|
||||||
<Spacer />
|
|
||||||
|
|
||||||
<Flex alignItems={'center'}>
|
|
||||||
<Stack direction={'row'} spacing={7}>
|
|
||||||
<Button onClick={toggleColorMode}>{colorMode === 'light' ? <MoonIcon /> : <SunIcon />}</Button>
|
|
||||||
</Stack>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MainMenu = ({ items }) => {
|
|
||||||
const linkColor = useColorModeValue('gray.600', 'gray.200')
|
|
||||||
const linkHoverColor = useColorModeValue('gray.800', 'white')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack direction={'row'} spacing={4} pl="4">
|
|
||||||
{items.map((navItem) => (
|
|
||||||
<Box key={navItem.label}>
|
|
||||||
<Link
|
|
||||||
p={2}
|
|
||||||
href={navItem.url ?? '#'}
|
|
||||||
fontSize={'sm'}
|
|
||||||
fontWeight={500}
|
|
||||||
color={linkColor}
|
|
||||||
_hover={{
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: linkHoverColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{navItem.label}
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Container, Box } from '@chakra-ui/react'
|
|
||||||
import Footer from '~/components/footer'
|
|
||||||
import Header from '~/components/header'
|
|
||||||
|
|
||||||
export default function Layout({ globals, menus, children }) {
|
|
||||||
return (
|
|
||||||
<Container maxW={'8xl'}>
|
|
||||||
<Header menu={menus.find((m) => m.id === 'MAIN_MENU')} globals={globals} />
|
|
||||||
<Box marginY="6">{children}</Box>
|
|
||||||
<Footer menu={menus.find((m) => m.id === 'FOOTER_MENU')} globals={globals} />
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { Flex, Link, Select, Text } from '@chakra-ui/react'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import NextLink from 'next/link'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { Fragment } from 'react'
|
|
||||||
|
|
||||||
export default function Pagination({ page = 1, itemsPerPage = 12, totalItems = 0, limit = 3 }) {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
|
||||||
|
|
||||||
if (totalPages === 1) return <></>
|
|
||||||
|
|
||||||
const links = new Array(totalPages)
|
|
||||||
.fill(true)
|
|
||||||
.map((_v, i) => (i < limit || i >= totalPages - limit || (i >= page - limit && i < page + limit - 1)) && i + 1)
|
|
||||||
.filter((i) => i !== false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Flex justifyContent="space-between" direction={['column', 'row']} mt={6} mb={6} gap={3}>
|
|
||||||
<Link
|
|
||||||
//
|
|
||||||
as={NextLink}
|
|
||||||
href={{ query: { ...router.query, page: Math.max(1, page - 1) } }}
|
|
||||||
variant="pagination"
|
|
||||||
>
|
|
||||||
← Previous
|
|
||||||
</Link>
|
|
||||||
<Flex gap={1} display={['none', 'none', 'flex']}>
|
|
||||||
{links.map((v, i) => (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{v > 1 && v - 1 !== links[i - 1] && (
|
|
||||||
<Text marginX={3} lineHeight={10}>
|
|
||||||
…
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
//
|
|
||||||
key={v}
|
|
||||||
as={NextLink}
|
|
||||||
prefetch={false}
|
|
||||||
href={{ query: { ...router.query, page: v } }}
|
|
||||||
className={clsx({
|
|
||||||
current: v === page,
|
|
||||||
})}
|
|
||||||
variant="pagination"
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</Link>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
<Select
|
|
||||||
value={page}
|
|
||||||
onChange={(v) => router.push({ query: { ...router.query, page: v.target.value } })}
|
|
||||||
display={['flex', 'flex', 'none']}
|
|
||||||
textAlign="center"
|
|
||||||
>
|
|
||||||
{new Array(totalPages).fill(true).map((v, i) => (
|
|
||||||
<option value={i + 1} key={i}>
|
|
||||||
{i + 1}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Link
|
|
||||||
//
|
|
||||||
as={NextLink}
|
|
||||||
href={{ query: { ...router.query, page: Math.min(totalPages, page + 1) } }}
|
|
||||||
variant="pagination"
|
|
||||||
textAlign="right"
|
|
||||||
>
|
|
||||||
Next →
|
|
||||||
</Link>
|
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PCA Pijac</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"~/*": ["./*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
@@ -2,26 +2,49 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22",
|
||||||
|
"pnpm": ">=10"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "next lint"
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"format": "prettier --ignore-unknown --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/icons": "2.0.19",
|
"@icons-pack/react-simple-icons": "13.13.0",
|
||||||
"@chakra-ui/next-js": "2.1.4",
|
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||||
"@chakra-ui/react": "2.7.1",
|
"@radix-ui/react-label": "2.1.8",
|
||||||
"@emotion/react": "11.11.1",
|
"@radix-ui/react-separator": "1.1.8",
|
||||||
"@emotion/styled": "11.11.0",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@react-hookz/web": "23.1.0",
|
"@tanstack/react-query": "5.100.1",
|
||||||
"clsx": "1.2.1",
|
"@tanstack/react-router": "1.168.23",
|
||||||
"iso-3166": "4.2.0",
|
"class-variance-authority": "0.7.1",
|
||||||
"next": "13.4.9",
|
"clsx": "2.1.1",
|
||||||
"react": "18.2.0",
|
"iso-3166": "4.4.0",
|
||||||
"react-dom": "18.2.0",
|
"lucide-react": "1.9.0",
|
||||||
"react-icons": "4.10.1",
|
"react": "19.2.5",
|
||||||
"sass": "1.63.6",
|
"react-dom": "19.2.5",
|
||||||
"swr": "2.2.0"
|
"react-helmet-async": "3.0.0",
|
||||||
|
"tailwind-merge": "3.5.0",
|
||||||
|
"tw-animate-css": "1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.2.4",
|
||||||
|
"@tanstack/router-plugin": "1.167.22",
|
||||||
|
"@types/node": "25.6.0",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"prettier": "3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "4.3.0",
|
||||||
|
"tailwindcss": "4.2.4",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"vite": "8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@chakra-ui/react'
|
|
||||||
import { TbMoodSad } from 'react-icons/tb'
|
|
||||||
|
|
||||||
export default function Custom404() {
|
|
||||||
return (
|
|
||||||
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
|
|
||||||
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
|
|
||||||
<AlertTitle mt={4} mb={1} fontSize="lg">
|
|
||||||
404: Page Not Found
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription maxWidth="sm">Try going back or something …</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@chakra-ui/react'
|
|
||||||
import { TbMoodSad } from 'react-icons/tb'
|
|
||||||
|
|
||||||
export default function Custom500() {
|
|
||||||
return (
|
|
||||||
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
|
|
||||||
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
|
|
||||||
<AlertTitle mt={4} mb={1} fontSize="lg">
|
|
||||||
500: Server-side error occurred
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription maxWidth="sm">Something happened that was totally unexpected …</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Box, Heading } from '@chakra-ui/react'
|
|
||||||
import Head from 'next/head'
|
|
||||||
|
|
||||||
export const getStaticPaths = async () => {
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/pages`)
|
|
||||||
url.searchParams.append('fields[]', 'slug')
|
|
||||||
url.searchParams.append('limit', -1)
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
|
|
||||||
const { data: pages } = await res.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths: pages.map((p) => ({ params: { slug: p.slug } })),
|
|
||||||
fallback: false, // false or "blocking"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps = async ({ params: { slug } }) => {
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/pages`)
|
|
||||||
url.searchParams.append('fields[]', 'title')
|
|
||||||
url.searchParams.append('fields[]', 'content')
|
|
||||||
url.searchParams.append('limit', 1)
|
|
||||||
url.searchParams.append('filter', JSON.stringify({ slug: { _eq: slug } }))
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: [page],
|
|
||||||
} = await res.json()
|
|
||||||
|
|
||||||
return { props: { page } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page({ globals, page }) {
|
|
||||||
return (
|
|
||||||
<Box className="page">
|
|
||||||
<Head>{page.title && <title>{[page.title, globals.meta_title].join(' — ')}</title>}</Head>
|
|
||||||
<Heading>{page.title}</Heading>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: page.content }}></div>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { ChakraProvider } from '@chakra-ui/react'
|
|
||||||
import App from 'next/app'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import Layout from '~/components/layout'
|
|
||||||
import '~/src/style.scss'
|
|
||||||
import theme from '~/src/theme'
|
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps, globals, menus }) {
|
|
||||||
return (
|
|
||||||
<ChakraProvider theme={theme}>
|
|
||||||
<Head>
|
|
||||||
<title>{globals.meta_title}</title>
|
|
||||||
<meta name="description" content={globals.meta_description} />
|
|
||||||
</Head>
|
|
||||||
<Layout globals={globals} menus={menus}>
|
|
||||||
<Component {...pageProps} globals={globals} />
|
|
||||||
</Layout>
|
|
||||||
</ChakraProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
MyApp.getInitialProps = async (context) => {
|
|
||||||
const pageProps = await App.getInitialProps(context)
|
|
||||||
|
|
||||||
let url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/globals`)
|
|
||||||
url.searchParams.append('fields[]', '*')
|
|
||||||
url.searchParams.append('limit', 1)
|
|
||||||
const resG = await fetch(url.toString())
|
|
||||||
let { data: globals } = await resG.json()
|
|
||||||
|
|
||||||
url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/menus`)
|
|
||||||
url.searchParams.append('fields[]', '*')
|
|
||||||
url.searchParams.append('fields[]', 'menus_menu_items.sort')
|
|
||||||
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.label')
|
|
||||||
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.url')
|
|
||||||
url.searchParams.append('fields[]', 'menus_menu_items.menu_items_id.sort')
|
|
||||||
url.searchParams.append('limit', -1)
|
|
||||||
const resM = await fetch(url.toString())
|
|
||||||
const { data: menus } = await resM.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
...pageProps,
|
|
||||||
globals,
|
|
||||||
menus: menus.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
items: m.menus_menu_items.sort((a, b) => a.sort - b.sort).map((mm) => mm.menu_items_id),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default function Error(props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2>Oops, there is an error!</h2>
|
|
||||||
<pre>{JSON.stringify(props, null, 2)}</pre>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import Page, { getStaticProps as gsp } from '~/pages/[slug]'
|
|
||||||
|
|
||||||
export const getStaticProps = async () => {
|
|
||||||
return gsp({ params: { slug: 'home' } })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home(props) {
|
|
||||||
return <Page {...props} />
|
|
||||||
}
|
|
||||||
164
frontend/pages/vendors/[slug]/index.js
vendored
164
frontend/pages/vendors/[slug]/index.js
vendored
@@ -1,164 +0,0 @@
|
|||||||
import { Link } from '@chakra-ui/next-js'
|
|
||||||
import { Box, Button, Flex, Heading, Image, List, ListIcon, ListItem, SimpleGrid, Text } from '@chakra-ui/react'
|
|
||||||
import { iso31661 } from 'iso-3166'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { FaFacebookSquare, FaLinkedin, FaTwitterSquare } from 'react-icons/fa'
|
|
||||||
import { TbWorldWww } from 'react-icons/tb'
|
|
||||||
|
|
||||||
export const getStaticPaths = async () => {
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
|
|
||||||
url.searchParams.append('fields[]', 'slug')
|
|
||||||
url.searchParams.append('limit', -1)
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
|
|
||||||
const { data: vendors } = await res.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
paths: vendors.map((v) => ({ params: { slug: v.slug } })),
|
|
||||||
fallback: false, // false or "blocking"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStaticProps = async ({ params: { slug } }) => {
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
|
|
||||||
url.searchParams.append('fields[]', '*')
|
|
||||||
url.searchParams.append('fields[]', 'categories.categories_id.slug')
|
|
||||||
url.searchParams.append('fields[]', 'categories.categories_id.name')
|
|
||||||
url.searchParams.append('fields[]', 'categories.categories_id.parent_id')
|
|
||||||
url.searchParams.append('fields[]', 'categories.categories_id.subcategories.slug')
|
|
||||||
url.searchParams.append('fields[]', 'categories.categories_id.subcategories.name')
|
|
||||||
url.searchParams.append('limit', 1)
|
|
||||||
url.searchParams.append('filter', JSON.stringify({ slug: { _eq: slug } }))
|
|
||||||
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: [vendor],
|
|
||||||
} = await res.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: { vendor: { ...vendor, country: iso31661.find((iso) => iso.alpha3 === vendor.country)?.name || '' } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorPage({ globals, vendor }) {
|
|
||||||
return (
|
|
||||||
<Box className="vendor">
|
|
||||||
<Head>
|
|
||||||
<title>{[vendor.name, globals.meta_title].join(' — ')}</title>
|
|
||||||
</Head>
|
|
||||||
<Flex justifyContent="space-between" direction={['column', 'column', 'row']}>
|
|
||||||
<Heading marginY="6">{vendor.name}</Heading>
|
|
||||||
<Image
|
|
||||||
rounded={4}
|
|
||||||
objectFit="contain"
|
|
||||||
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${vendor.logo}?key=logo-page`}
|
|
||||||
alt={vendor.name}
|
|
||||||
width="350px"
|
|
||||||
height="150px"
|
|
||||||
bg="#fff"
|
|
||||||
/>
|
|
||||||
</Flex>
|
|
||||||
<Box dangerouslySetInnerHTML={{ __html: vendor.long_description }}></Box>
|
|
||||||
|
|
||||||
<SimpleGrid columns={[1, 2, 3]} spacing={10}>
|
|
||||||
<Box>
|
|
||||||
<Heading size="md" mt="6" mb="1">
|
|
||||||
Address
|
|
||||||
</Heading>
|
|
||||||
<Text>
|
|
||||||
{vendor.address_line_1 && (
|
|
||||||
<>
|
|
||||||
{vendor.address_line_1}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{vendor.address_line_2 && (
|
|
||||||
<>
|
|
||||||
{vendor.address_line_2}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{vendor.city && (
|
|
||||||
<>
|
|
||||||
{vendor.city}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{vendor.state && (
|
|
||||||
<>
|
|
||||||
{vendor.state}
|
|
||||||
<br />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{vendor.country && <>{vendor.country}</>}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
{(vendor.website || vendor.facebook || vendor.linkedin || vendor.twitter) && (
|
|
||||||
<>
|
|
||||||
<Heading size="md" mt="6" mb="1">
|
|
||||||
Social
|
|
||||||
</Heading>
|
|
||||||
<List>
|
|
||||||
{vendor.website && (
|
|
||||||
<ListItem>
|
|
||||||
<ListIcon as={TbWorldWww} />
|
|
||||||
{vendor.website}
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
{vendor.linkedin && (
|
|
||||||
<ListItem>
|
|
||||||
<ListIcon as={FaLinkedin} />
|
|
||||||
{vendor.linkedin}
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
{vendor.twitter && (
|
|
||||||
<ListItem>
|
|
||||||
<ListIcon as={FaTwitterSquare} />
|
|
||||||
{vendor.twitter}
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
{vendor.facebook && (
|
|
||||||
<ListItem>
|
|
||||||
<ListIcon as={FaFacebookSquare} />
|
|
||||||
{vendor.facebook}
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
{vendor.categories.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Heading size="md" mt="6" mb="1">
|
|
||||||
Categories
|
|
||||||
</Heading>
|
|
||||||
<List spacing="3">
|
|
||||||
{vendor.categories.map((cat) => (
|
|
||||||
<ListItem key={cat.categories_id.slug}>
|
|
||||||
<Button
|
|
||||||
as={Link}
|
|
||||||
fontSize="11"
|
|
||||||
href={{
|
|
||||||
pathname: '/vendors',
|
|
||||||
query: {
|
|
||||||
category: cat.categories_id.slug,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
paddingX="0.5rem"
|
|
||||||
height="1.5rem"
|
|
||||||
>
|
|
||||||
{cat.categories_id.name}
|
|
||||||
</Button>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
310
frontend/pages/vendors/index.js
vendored
310
frontend/pages/vendors/index.js
vendored
@@ -1,310 +0,0 @@
|
|||||||
import { Link } from '@chakra-ui/next-js'
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertIcon,
|
|
||||||
AlertTitle,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Card,
|
|
||||||
CardBody,
|
|
||||||
Flex,
|
|
||||||
HStack,
|
|
||||||
Heading,
|
|
||||||
IconButton,
|
|
||||||
Image,
|
|
||||||
Input,
|
|
||||||
InputGroup,
|
|
||||||
InputLeftElement,
|
|
||||||
InputRightElement,
|
|
||||||
LinkBox,
|
|
||||||
LinkOverlay,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuDivider,
|
|
||||||
MenuItem,
|
|
||||||
MenuList,
|
|
||||||
SimpleGrid,
|
|
||||||
Skeleton,
|
|
||||||
Spinner,
|
|
||||||
Table,
|
|
||||||
Tbody,
|
|
||||||
Td,
|
|
||||||
Text,
|
|
||||||
Tr,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { useDebouncedCallback, useLocalStorageValue } from '@react-hookz/web'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { TbCheck, TbFilter, TbFilterEdit, TbLayoutGrid, TbLayoutList, TbMoodSad, TbSearch, TbX } from 'react-icons/tb'
|
|
||||||
import useSWR from 'swr'
|
|
||||||
import Pagination from '~/components/pagination'
|
|
||||||
|
|
||||||
const PER_PAGE = 12
|
|
||||||
|
|
||||||
export const getStaticProps = async () => {
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/categories`)
|
|
||||||
url.searchParams.append('fields[]', 'slug')
|
|
||||||
url.searchParams.append('fields[]', 'name')
|
|
||||||
url.searchParams.append('fields[]', 'subcategories.slug')
|
|
||||||
url.searchParams.append('fields[]', 'subcategories.name')
|
|
||||||
url.searchParams.append('sort', 'name')
|
|
||||||
url.searchParams.append('limit', -1)
|
|
||||||
url.searchParams.append(
|
|
||||||
'filter',
|
|
||||||
JSON.stringify({
|
|
||||||
parent_id: {
|
|
||||||
_null: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
|
|
||||||
const { data: categories } = await res.json()
|
|
||||||
|
|
||||||
return { props: { categories } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorsPage({ globals, categories }) {
|
|
||||||
const router = useRouter()
|
|
||||||
const {
|
|
||||||
query: { page = 1, category = '', q: search = '' },
|
|
||||||
} = router
|
|
||||||
|
|
||||||
const { value: perPage, set: setPerPage } = useLocalStorageValue('perPage', {
|
|
||||||
defaultValue: PER_PAGE,
|
|
||||||
initializeWithValue: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { value: layout, set: setLayout } = useLocalStorageValue('layout', {
|
|
||||||
defaultValue: 'GRID',
|
|
||||||
initializeWithValue: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const setSearch = useDebouncedCallback(
|
|
||||||
(q) => {
|
|
||||||
router.replace({ query: { ...router.query, q } })
|
|
||||||
},
|
|
||||||
[router],
|
|
||||||
500
|
|
||||||
)
|
|
||||||
|
|
||||||
const { data, error, isLoading, isValidating } = useSWR(['vendors', page, perPage, search, category], async () => {
|
|
||||||
if (!perPage) return
|
|
||||||
|
|
||||||
const url = new URL(`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/items/vendors`)
|
|
||||||
url.searchParams.append('fields[]', '*')
|
|
||||||
url.searchParams.append('limit', perPage)
|
|
||||||
url.searchParams.append('page', page)
|
|
||||||
url.searchParams.append('sort', 'name')
|
|
||||||
url.searchParams.append('meta[]', 'filter_count')
|
|
||||||
|
|
||||||
if (search !== '' || category !== '') {
|
|
||||||
url.searchParams.append(
|
|
||||||
'filter',
|
|
||||||
JSON.stringify({
|
|
||||||
_and: [
|
|
||||||
category !== '' ? { categories: { categories_id: { slug: { _eq: category } } } } : {},
|
|
||||||
search !== ''
|
|
||||||
? {
|
|
||||||
_or: ['name', 'description', 'long_description', 'city'].map((s) => ({
|
|
||||||
[s]: { _contains: search },
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url.toString())
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Oops')
|
|
||||||
}
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
|
|
||||||
const { meta, data: vendors } = data || { meta: {}, data: [] }
|
|
||||||
|
|
||||||
const lastPage = Math.max(1, Math.ceil(meta.filter_count / perPage))
|
|
||||||
if (!isNaN(lastPage) && page > lastPage) {
|
|
||||||
router.replace({ query: { ...router.query, page: lastPage } })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="vendors">
|
|
||||||
<Head>
|
|
||||||
<title>{['Vendors', globals.meta_title].join(' — ')}</title>
|
|
||||||
</Head>
|
|
||||||
<Flex alignItems="center" justifyContent="space-between" direction={['column', 'row']} mb="5" gap="5">
|
|
||||||
<Heading size="lg">Vendors</Heading>
|
|
||||||
<Box>{isValidating && <Spinner />}</Box>
|
|
||||||
<HStack gap="5">
|
|
||||||
<InputGroup>
|
|
||||||
<InputLeftElement>
|
|
||||||
<TbSearch />
|
|
||||||
</InputLeftElement>
|
|
||||||
<Input onChange={(ev) => setSearch(ev.target.value.toLowerCase())} />
|
|
||||||
<InputRightElement
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.currentTarget.parentNode.childNodes[1].value = ''
|
|
||||||
setSearch('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{search !== '' && <TbX />}
|
|
||||||
</InputRightElement>
|
|
||||||
</InputGroup>
|
|
||||||
<Menu>
|
|
||||||
<MenuButton as={IconButton} icon={category === '' ? <TbFilter /> : <TbFilterEdit />} />
|
|
||||||
<MenuList>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<Link key={cat.slug} prefetch={false} href={{ query: { ...router.query, category: cat.slug } }}>
|
|
||||||
<MenuItem icon={category === cat.slug && <TbCheck />} isDisabled={category === cat.slug}>
|
|
||||||
{cat.name}
|
|
||||||
</MenuItem>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
<MenuDivider />
|
|
||||||
<Link href={{ query: { ...router.query, category: '' } }}>
|
|
||||||
<MenuItem>Clear</MenuItem>
|
|
||||||
</Link>
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
<ButtonGroup isAttached>
|
|
||||||
<IconButton icon={<TbLayoutGrid />} onClick={() => setLayout('GRID')} isDisabled={layout === 'GRID'} />
|
|
||||||
<IconButton icon={<TbLayoutList />} onClick={() => setLayout('LIST')} isDisabled={layout === 'LIST'} />
|
|
||||||
</ButtonGroup>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert status="error" marginY={3}>
|
|
||||||
<AlertIcon />
|
|
||||||
There was an error processing your request
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{layout === 'GRID' && (
|
|
||||||
<SimpleGrid columns={[1, 2, 3, 4]} spacing={3}>
|
|
||||||
{isLoading &&
|
|
||||||
new Array(perPage).fill(true).map((v, k) => (
|
|
||||||
<Card key={k}>
|
|
||||||
<Skeleton h="40" />
|
|
||||||
<CardBody>
|
|
||||||
<Skeleton h="6" mb="2" />
|
|
||||||
<Skeleton h="4" mb="1" />
|
|
||||||
<Skeleton h="4" />
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
{!isLoading &&
|
|
||||||
vendors.map((v) => (
|
|
||||||
<LinkBox as={Card} rounded={4} key={v.id}>
|
|
||||||
<Image
|
|
||||||
roundedTop={4}
|
|
||||||
objectFit="contain"
|
|
||||||
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${v.logo}?key=logo-card`}
|
|
||||||
alt={v.name}
|
|
||||||
width="250"
|
|
||||||
height="150"
|
|
||||||
style={{ objectFit: 'contain', background: '#fff' }}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
<LinkOverlay as={Link} href={`/vendors/${v.slug}`} prefetch={false}>
|
|
||||||
<Text mb="2" fontWeight="900">
|
|
||||||
{v.name}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="sm">
|
|
||||||
{v?.description?.substring(0, v?.description?.substring(0, 80).lastIndexOf(' '))} …
|
|
||||||
</Text>
|
|
||||||
</LinkOverlay>
|
|
||||||
</CardBody>
|
|
||||||
</LinkBox>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{layout === 'LIST' && (
|
|
||||||
<>
|
|
||||||
{isLoading && (
|
|
||||||
<Table>
|
|
||||||
<Tbody>
|
|
||||||
{new Array(perPage).fill(true).map((v, k) => (
|
|
||||||
<Tr key={k}>
|
|
||||||
<Td w="10%">
|
|
||||||
<Skeleton h="12" w="12" />
|
|
||||||
</Td>
|
|
||||||
<Td w="30%">
|
|
||||||
<Skeleton h="6" />
|
|
||||||
</Td>
|
|
||||||
<Td w="60%">
|
|
||||||
<Skeleton h="6" />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
{!isLoading && (
|
|
||||||
<Table>
|
|
||||||
<Tbody>
|
|
||||||
{vendors.map((v) => (
|
|
||||||
<Link key={v.id} href={`/vendors/${v.slug}`} prefetch={false} display="contents">
|
|
||||||
<Tr verticalAlign="middle">
|
|
||||||
<Td>
|
|
||||||
<Image
|
|
||||||
rounded="md"
|
|
||||||
src={`${process.env.NEXT_PUBLIC_DIRECTUS_API_URL}/assets/${v.logo}?key=logo-card`}
|
|
||||||
alt={v.name}
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
style={{ objectFit: 'contain', background: '#fff' }}
|
|
||||||
/>
|
|
||||||
</Td>
|
|
||||||
<Td>{v.name}</Td>
|
|
||||||
<Td>
|
|
||||||
<Text fontSize="sm">
|
|
||||||
{v?.description?.substring(0, v?.description?.substring(0, 80).lastIndexOf(' '))} …
|
|
||||||
</Text>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{meta.filter_count === 0 && (
|
|
||||||
<Box>
|
|
||||||
<Alert status="info" flexDirection="column" justifyContent="center" rounded="md" paddingY="10">
|
|
||||||
<TbMoodSad style={{ width: '2rem', height: '2rem' }} />
|
|
||||||
<AlertTitle mt={4} mb={1} fontSize="lg">
|
|
||||||
No results found.
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription maxWidth="sm">Try refining your search term and filters …</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{meta.filter_count > perPage && (
|
|
||||||
<>
|
|
||||||
<Pagination page={Number(page)} itemsPerPage={perPage} totalItems={meta.filter_count} />
|
|
||||||
<HStack mt="5" justifyContent="center">
|
|
||||||
<Text>Results per page:</Text>
|
|
||||||
<ButtonGroup isAttached>
|
|
||||||
{[12, 24, 48].map((n) => (
|
|
||||||
<Button key={n} onClick={() => setPerPage(n)} isDisabled={perPage === n}>
|
|
||||||
{n}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</ButtonGroup>
|
|
||||||
</HStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
4945
frontend/pnpm-lock.yaml
generated
4945
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
25
frontend/src/components/error-state.tsx
Normal file
25
frontend/src/components/error-state.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
error: Error
|
||||||
|
reset?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({ error, reset }: Props) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive" className="my-3 rounded-md py-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<AlertTriangle className="h-8 w-8" />
|
||||||
|
<AlertTitle className="mt-4 text-lg">Something went wrong</AlertTitle>
|
||||||
|
<AlertDescription className="max-w-sm">{error.message}</AlertDescription>
|
||||||
|
{reset && (
|
||||||
|
<Button variant="outline" size="sm" onClick={reset} className="mt-3">
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
frontend/src/components/layout/footer.tsx
Normal file
27
frontend/src/components/layout/footer.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Globals, Menu } from '~/lib/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
globals: Globals
|
||||||
|
menu: Menu | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ globals, menu }: Props) {
|
||||||
|
return (
|
||||||
|
<footer className="mt-8 bg-muted/50">
|
||||||
|
<div className="flex h-16 items-center justify-between gap-4 px-4">
|
||||||
|
<nav className="flex gap-1">
|
||||||
|
{menu?.items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.label}
|
||||||
|
href={item.url ?? '#'}
|
||||||
|
className="px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<p className="text-sm text-muted-foreground">{globals.copyright}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/layout/header.tsx
Normal file
34
frontend/src/components/layout/header.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import type { Globals, Menu } from '~/lib/types'
|
||||||
|
import { ThemeToggle } from './theme-toggle'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
globals: Globals
|
||||||
|
menu: Menu | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ globals, menu }: Props) {
|
||||||
|
return (
|
||||||
|
<header className="bg-muted/50">
|
||||||
|
<div className="flex h-16 items-center gap-4 px-4">
|
||||||
|
<Link to="/" className="text-base font-semibold hover:no-underline">
|
||||||
|
{globals.site_name}
|
||||||
|
</Link>
|
||||||
|
<nav className="flex gap-1 pl-2">
|
||||||
|
{menu?.items.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.label}
|
||||||
|
href={item.url ?? '#'}
|
||||||
|
className="px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/layout/shell.tsx
Normal file
20
frontend/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { globalsQuery, menusQuery } from '~/lib/queries'
|
||||||
|
import { Footer } from './footer'
|
||||||
|
import { Header } from './header'
|
||||||
|
|
||||||
|
export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: globals } = useSuspenseQuery(globalsQuery)
|
||||||
|
const { data: menus } = useSuspenseQuery(menusQuery)
|
||||||
|
|
||||||
|
const mainMenu = menus.find((m) => m.id === 'MAIN_MENU')
|
||||||
|
const footerMenu = menus.find((m) => m.id === 'FOOTER_MENU')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[90rem] px-4">
|
||||||
|
<Header globals={globals} menu={mainMenu} />
|
||||||
|
<main className="my-6">{children}</main>
|
||||||
|
<Footer globals={globals} menu={footerMenu} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
frontend/src/components/layout/theme-toggle.tsx
Normal file
12
frontend/src/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { useTheme } from '~/components/theme-provider'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="icon" onClick={toggle} aria-label="Toggle theme">
|
||||||
|
{theme === 'light' ? <Moon /> : <Sun />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
frontend/src/components/not-found.tsx
Normal file
14
frontend/src/components/not-found.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Frown } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
|
||||||
|
|
||||||
|
export function NotFound({ title = '404: Page Not Found', message = 'Try going back or something …' }) {
|
||||||
|
return (
|
||||||
|
<Alert variant="info" className="my-3 rounded-md py-10">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<Frown className="h-8 w-8" />
|
||||||
|
<AlertTitle className="mt-4 text-lg">{title}</AlertTitle>
|
||||||
|
<AlertDescription className="max-w-sm">{message}</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/components/page-view.tsx
Normal file
13
frontend/src/components/page-view.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
type Props = {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageView({ title, content }: Props) {
|
||||||
|
return (
|
||||||
|
<article className="page">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">{title}</h1>
|
||||||
|
<div className="prose-cms" dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
frontend/src/components/pagination.tsx
Normal file
81
frontend/src/components/pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
page: number
|
||||||
|
itemsPerPage: number
|
||||||
|
totalItems: number
|
||||||
|
limit?: number
|
||||||
|
buildSearch: (nextPage: number) => Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, itemsPerPage, totalItems, limit = 3, buildSearch }: Props) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||||
|
if (totalPages === 1) return null
|
||||||
|
|
||||||
|
const pages: number[] = []
|
||||||
|
for (let i = 0; i < totalPages; i++) {
|
||||||
|
if (i < limit || i >= totalPages - limit || (i >= page - limit && i < page + limit - 1)) {
|
||||||
|
pages.push(i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = Math.max(1, page - 1)
|
||||||
|
const next = Math.min(totalPages, page + 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<Link
|
||||||
|
to="."
|
||||||
|
search={buildSearch(prev)}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="hidden gap-1 md:flex">
|
||||||
|
{pages.map((p, idx) => (
|
||||||
|
<div key={p} className="flex items-center">
|
||||||
|
{idx > 0 && p - 1 !== pages[idx - 1] && <span className="mx-2 leading-10">…</span>}
|
||||||
|
<Link
|
||||||
|
to="."
|
||||||
|
search={buildSearch(p)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
p === page && 'bg-accent font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border bg-transparent px-3 text-sm md:hidden"
|
||||||
|
value={page}
|
||||||
|
onChange={(e) => {
|
||||||
|
const target = Number(e.target.value)
|
||||||
|
const hash = Object.entries(buildSearch(target))
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('&')
|
||||||
|
window.location.search = `?${hash}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="."
|
||||||
|
search={buildSearch(next)}
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md border px-3 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
frontend/src/components/theme-provider.tsx
Normal file
49
frontend/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
type ThemeContextValue = {
|
||||||
|
theme: Theme
|
||||||
|
toggle: () => void
|
||||||
|
set: (t: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme'
|
||||||
|
|
||||||
|
function readInitial(): Theme {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(readInitial)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(theme)
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
theme,
|
||||||
|
toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
|
||||||
|
set: setTheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext)
|
||||||
|
if (!ctx) throw new Error('useTheme must be used inside ThemeProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
41
frontend/src/components/ui/alert.tsx
Normal file
41
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
info: 'border-primary/30 bg-primary/5 text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
))
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
export const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
AlertTitle.displayName = 'AlertTitle'
|
||||||
|
|
||||||
|
export const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = 'AlertDescription'
|
||||||
45
frontend/src/components/ui/button.tsx
Normal file
45
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { buttonVariants }
|
||||||
46
frontend/src/components/ui/card.tsx
Normal file
46
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
export const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
export const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
export const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
export const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardDescription.displayName = 'CardDescription'
|
||||||
|
|
||||||
|
export const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||||
|
)
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
export const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
95
frontend/src/components/ui/dropdown-menu.tsx
Normal file
95
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { Check } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
export const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
export const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
export const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
export const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
|
'focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
export const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
export const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
19
frontend/src/components/ui/input.tsx
Normal file
19
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
21
frontend/src/components/ui/separator.tsx
Normal file
21
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
export const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
5
frontend/src/components/ui/skeleton.tsx
Normal file
5
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { cn } from '~/lib/utils'
|
||||||
|
|
||||||
|
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />
|
||||||
|
}
|
||||||
15
frontend/src/hooks/use-debounced-callback.ts
Normal file
15
frontend/src/hooks/use-debounced-callback.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
|
||||||
|
export function useDebouncedCallback<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
||||||
|
const ref = useRef(fn)
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = fn
|
||||||
|
})
|
||||||
|
return useMemo(() => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => ref.current(...args), delay)
|
||||||
|
}) as T
|
||||||
|
}, [delay])
|
||||||
|
}
|
||||||
24
frontend/src/hooks/use-local-storage.ts
Normal file
24
frontend/src/hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, (v: T) => void] {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
if (typeof window === 'undefined') return initialValue
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
return raw != null ? (JSON.parse(raw) as T) : initialValue
|
||||||
|
} catch {
|
||||||
|
return initialValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
}, [key, value])
|
||||||
|
|
||||||
|
const set = useCallback((v: T) => setValue(v), [])
|
||||||
|
return [value, set]
|
||||||
|
}
|
||||||
135
frontend/src/index.css
Normal file
135
frontend/src/index.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.prose-cms {
|
||||||
|
@apply text-foreground leading-relaxed;
|
||||||
|
}
|
||||||
|
.prose-cms h1,
|
||||||
|
.prose-cms h2,
|
||||||
|
.prose-cms h3,
|
||||||
|
.prose-cms h4,
|
||||||
|
.prose-cms h5,
|
||||||
|
.prose-cms h6 {
|
||||||
|
@apply font-bold mt-6 mb-1;
|
||||||
|
}
|
||||||
|
.prose-cms h1 {
|
||||||
|
@apply text-3xl;
|
||||||
|
}
|
||||||
|
.prose-cms h2 {
|
||||||
|
@apply text-2xl;
|
||||||
|
}
|
||||||
|
.prose-cms h3 {
|
||||||
|
@apply text-xl;
|
||||||
|
}
|
||||||
|
.prose-cms h4,
|
||||||
|
.prose-cms h5 {
|
||||||
|
@apply text-lg;
|
||||||
|
}
|
||||||
|
.prose-cms p {
|
||||||
|
@apply my-3;
|
||||||
|
}
|
||||||
|
.prose-cms ul {
|
||||||
|
@apply list-disc ml-8 my-3;
|
||||||
|
}
|
||||||
|
.prose-cms ol {
|
||||||
|
@apply list-decimal ml-8 my-3;
|
||||||
|
}
|
||||||
|
.prose-cms a {
|
||||||
|
@apply text-primary underline underline-offset-4;
|
||||||
|
}
|
||||||
|
.prose-cms img {
|
||||||
|
@apply rounded-md my-4;
|
||||||
|
}
|
||||||
|
.prose-cms blockquote {
|
||||||
|
@apply border-l-4 border-muted-foreground pl-4 italic my-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/lib/directus.ts
Normal file
62
frontend/src/lib/directus.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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 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>
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
110
frontend/src/lib/queries.ts
Normal file
110
frontend/src/lib/queries.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
import { directusList, directusOne, directusSingleton } from './directus'
|
||||||
|
import type { Category, Globals, Menu, MenuRaw, PageEntity, Vendor, VendorListItem } from './types'
|
||||||
|
|
||||||
|
export const globalsQuery = queryOptions({
|
||||||
|
queryKey: ['globals'],
|
||||||
|
queryFn: () => directusSingleton<Globals>('globals', { fields: ['*'] }),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
42
frontend/src/main.tsx
Normal file
42
frontend/src/main.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
|
import { routeTree } from '~generated/tanstack-router/routeTree.gen'
|
||||||
|
import { ThemeProvider } from './components/theme-provider'
|
||||||
|
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(
|
||||||
|
<StrictMode>
|
||||||
|
<HelmetProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</HelmetProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
32
frontend/src/routes/$slug.tsx
Normal file
32
frontend/src/routes/$slug.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { NotFound } from '~/components/not-found'
|
||||||
|
import { PageView } from '~/components/page-view'
|
||||||
|
import { pageBySlugQuery } from '~/lib/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/$slug')({
|
||||||
|
loader: async ({ context: { queryClient }, params: { slug } }) => {
|
||||||
|
const page = await queryClient.ensureQueryData(pageBySlugQuery(slug))
|
||||||
|
if (!page) throw notFound()
|
||||||
|
},
|
||||||
|
component: CmsPage,
|
||||||
|
notFoundComponent: () => <NotFound />,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CmsPage() {
|
||||||
|
const { slug } = Route.useParams()
|
||||||
|
const { data: page } = useSuspenseQuery(pageBySlugQuery(slug))
|
||||||
|
if (!page) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{page.title && (
|
||||||
|
<Helmet>
|
||||||
|
<title>{page.title}</title>
|
||||||
|
</Helmet>
|
||||||
|
)}
|
||||||
|
<PageView title={page.title} content={page.content} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/routes/__root.tsx
Normal file
39
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { ErrorState } from '~/components/error-state'
|
||||||
|
import { Shell } from '~/components/layout/shell'
|
||||||
|
import { NotFound } from '~/components/not-found'
|
||||||
|
import { globalsQuery, menusQuery } from '~/lib/queries'
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await Promise.all([queryClient.ensureQueryData(globalsQuery), queryClient.ensureQueryData(menusQuery)])
|
||||||
|
},
|
||||||
|
component: RootComponent,
|
||||||
|
notFoundComponent: () => (
|
||||||
|
<Shell>
|
||||||
|
<NotFound />
|
||||||
|
</Shell>
|
||||||
|
),
|
||||||
|
errorComponent: ({ error, reset }) => (
|
||||||
|
<Shell>
|
||||||
|
<ErrorState error={error} reset={reset} />
|
||||||
|
</Shell>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
const { data: globals } = useSuspenseQuery(globalsQuery)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet defaultTitle={globals.meta_title} titleTemplate={`%s — ${globals.meta_title}`}>
|
||||||
|
<meta name="description" content={globals.meta_description} />
|
||||||
|
</Helmet>
|
||||||
|
<Shell>
|
||||||
|
<Outlet />
|
||||||
|
</Shell>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
frontend/src/routes/index.tsx
Normal file
24
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { PageView } from '~/components/page-view'
|
||||||
|
import { pageBySlugQuery } from '~/lib/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await queryClient.ensureQueryData(pageBySlugQuery('home'))
|
||||||
|
},
|
||||||
|
component: HomePage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
const { data: page } = useSuspenseQuery(pageBySlugQuery('home'))
|
||||||
|
if (!page) {
|
||||||
|
return (
|
||||||
|
<div className="py-10">
|
||||||
|
<h1 className="text-3xl font-bold">Welcome</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">No home page content configured in Directus.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <PageView title={page.title} content={page.content} />
|
||||||
|
}
|
||||||
150
frontend/src/routes/vendors/$slug.tsx
vendored
Normal file
150
frontend/src/routes/vendors/$slug.tsx
vendored
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { SiFacebook, SiX } from '@icons-pack/react-simple-icons'
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute, Link, notFound } from '@tanstack/react-router'
|
||||||
|
import { iso31661 } from 'iso-3166'
|
||||||
|
import { ExternalLink, Globe } from 'lucide-react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { assetUrl } from '~/lib/directus'
|
||||||
|
import { vendorBySlugQuery } from '~/lib/queries'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/vendors/$slug')({
|
||||||
|
loader: async ({ context: { queryClient }, params: { slug } }) => {
|
||||||
|
const vendor = await queryClient.ensureQueryData(vendorBySlugQuery(slug))
|
||||||
|
if (!vendor) throw notFound()
|
||||||
|
},
|
||||||
|
component: VendorDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function countryName(alpha3: string | null | undefined): string {
|
||||||
|
if (!alpha3) return ''
|
||||||
|
return iso31661.find((iso) => iso.alpha3 === alpha3)?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function VendorDetailPage() {
|
||||||
|
const { slug } = Route.useParams()
|
||||||
|
const { data: vendor } = useSuspenseQuery(vendorBySlugQuery(slug))
|
||||||
|
if (!vendor) return null
|
||||||
|
|
||||||
|
const country = countryName(vendor.country)
|
||||||
|
const hasSocial = vendor.website || vendor.facebook || vendor.linkedin || vendor.twitter
|
||||||
|
const hasAddress = vendor.address_line_1 || vendor.address_line_2 || vendor.city || vendor.state || country
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="vendor">
|
||||||
|
<Helmet>
|
||||||
|
<title>{vendor.name}</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start justify-between gap-6 md:flex-row md:items-center">
|
||||||
|
<h1 className="text-3xl font-bold">{vendor.name}</h1>
|
||||||
|
{vendor.logo && (
|
||||||
|
<img
|
||||||
|
src={assetUrl(vendor.logo, 'logo-page')}
|
||||||
|
alt={vendor.name}
|
||||||
|
width={350}
|
||||||
|
height={150}
|
||||||
|
className="h-[150px] w-[350px] rounded-md bg-white object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vendor.long_description && (
|
||||||
|
<div className="prose-cms mt-6" dangerouslySetInnerHTML={{ __html: vendor.long_description }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{hasAddress && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mt-6 mb-2 text-lg font-semibold">Address</h2>
|
||||||
|
<p className="text-sm">
|
||||||
|
{vendor.address_line_1 && (
|
||||||
|
<>
|
||||||
|
{vendor.address_line_1}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{vendor.address_line_2 && (
|
||||||
|
<>
|
||||||
|
{vendor.address_line_2}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{vendor.city && (
|
||||||
|
<>
|
||||||
|
{vendor.city}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{vendor.state && (
|
||||||
|
<>
|
||||||
|
{vendor.state}
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{country && <>{country}</>}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSocial && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mt-6 mb-2 text-lg font-semibold">Social</h2>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{vendor.website && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<a href={vendor.website} target="_blank" rel="noreferrer" className="hover:underline">
|
||||||
|
{vendor.website}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{vendor.linkedin && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
<a href={vendor.linkedin} target="_blank" rel="noreferrer" className="hover:underline">
|
||||||
|
{vendor.linkedin}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{vendor.twitter && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<SiX className="h-4 w-4" />
|
||||||
|
<a href={vendor.twitter} target="_blank" rel="noreferrer" className="hover:underline">
|
||||||
|
{vendor.twitter}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{vendor.facebook && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<SiFacebook className="h-4 w-4" />
|
||||||
|
<a href={vendor.facebook} target="_blank" rel="noreferrer" className="hover:underline">
|
||||||
|
{vendor.facebook}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vendor.categories.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mt-6 mb-2 text-lg font-semibold">Categories</h2>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{vendor.categories.map((cat) => (
|
||||||
|
<li key={cat.categories_id.slug}>
|
||||||
|
<Button asChild variant="secondary" size="sm" className="h-6 px-2 text-[11px]">
|
||||||
|
<Link to="/vendors" search={{ category: cat.categories_id.slug }}>
|
||||||
|
{cat.categories_id.name}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
316
frontend/src/routes/vendors/index.tsx
vendored
Normal file
316
frontend/src/routes/vendors/index.tsx
vendored
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
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 { 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 { 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
|
||||||
|
q?: string
|
||||||
|
category?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/vendors/')({
|
||||||
|
validateSearch: (search: Record<string, unknown>): Search => ({
|
||||||
|
page: search.page ? Number(search.page) : undefined,
|
||||||
|
q: typeof search.q === 'string' ? search.q : undefined,
|
||||||
|
category: typeof search.category === 'string' ? search.category : undefined,
|
||||||
|
}),
|
||||||
|
loaderDeps: ({ search }) => search,
|
||||||
|
loader: async ({ context: { queryClient } }) => {
|
||||||
|
await queryClient.ensureQueryData(categoriesQuery)
|
||||||
|
},
|
||||||
|
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()
|
||||||
|
|
||||||
|
const { data: categories } = useSuspenseQuery(categoriesQuery)
|
||||||
|
|
||||||
|
const [perPage, setPerPage] = useLocalStorage<number>('perPage', 12)
|
||||||
|
const [layout, setLayout] = useLocalStorage<Layout>('layout', 'GRID')
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState(q)
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchInput(q)
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
const pushSearch = useDebouncedCallback((value: string) => {
|
||||||
|
navigate({ search: (s) => ({ ...s, q: value || undefined, page: undefined }) })
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: response,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery(vendorsListQuery({ page, perPage, search: q, category }))
|
||||||
|
|
||||||
|
const vendors = response?.data ?? []
|
||||||
|
const filterCount = response?.meta?.filter_count ?? 0
|
||||||
|
const lastPage = Math.max(1, Math.ceil(filterCount / perPage))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (response && page > lastPage) {
|
||||||
|
navigate({ search: (s) => ({ ...s, page: lastPage }), replace: true })
|
||||||
|
}
|
||||||
|
}, [response, page, lastPage, navigate])
|
||||||
|
|
||||||
|
const isEmpty = !isFetching && filterCount === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<Helmet>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive" className="my-3">
|
||||||
|
<AlertDescription>There was an error processing your request</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty && (
|
||||||
|
<Alert variant="info" className={cn('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>
|
||||||
|
<AlertDescription className="max-w-sm">
|
||||||
|
Try refining your search term and filters …
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterCount > perPage && (
|
||||||
|
<>
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
itemsPerPage={perPage}
|
||||||
|
totalItems={filterCount}
|
||||||
|
buildSearch={(p) => ({
|
||||||
|
q: q || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.page {
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4,
|
|
||||||
h5 {
|
|
||||||
font-family: var(--chakra-fonts-heading);
|
|
||||||
font-weight: var(--chakra-fontWeights-bold);
|
|
||||||
margin-top: var(--chakra-space-6);
|
|
||||||
margin-bottom: var(--chakra-space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin-left: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.vendor {
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
font-size: var(--chakra-fontSizes-xl);
|
|
||||||
font-family: var(--chakra-fonts-heading);
|
|
||||||
font-weight: var(--chakra-fontWeights-bold);
|
|
||||||
margin-top: var(--chakra-space-6);
|
|
||||||
margin-bottom: var(--chakra-space-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { extendTheme } from '@chakra-ui/react'
|
|
||||||
import { defineStyle, defineStyleConfig } from '@chakra-ui/react'
|
|
||||||
|
|
||||||
const pagination = defineStyle({
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
bg: 'transparent',
|
|
||||||
gap: 3,
|
|
||||||
paddingInlineStart: 3,
|
|
||||||
paddingInlineEnd: 3,
|
|
||||||
lineHeight: 10,
|
|
||||||
borderRadius: 'md',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'inherit',
|
|
||||||
width: '100%',
|
|
||||||
_hover: {
|
|
||||||
textDecor: 'none',
|
|
||||||
},
|
|
||||||
'&.current': {
|
|
||||||
bg: 'var(--chakra-colors-gray-300)',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
},
|
|
||||||
_dark: {
|
|
||||||
bg: 'var(--chakra-colors-gray-900)',
|
|
||||||
'&.current': {
|
|
||||||
bg: 'var(--chakra-colors-gray-700)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const linkTheme = defineStyleConfig({
|
|
||||||
variants: { pagination },
|
|
||||||
})
|
|
||||||
|
|
||||||
const theme = extendTheme({
|
|
||||||
components: {
|
|
||||||
Link: linkTheme,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export default theme
|
|
||||||
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
|
||||||
|
}
|
||||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.cache/tsc/app.tsbuildinfo",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./src/*"],
|
||||||
|
"~generated/*": ["./node_modules/.cache/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "node_modules/.cache/tanstack-router/routeTree.gen.ts"]
|
||||||
|
}
|
||||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
18
frontend/tsconfig.node.json
Normal file
18
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.cache/tsc/node.tsbuildinfo",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
32
frontend/vite.config.ts
Normal file
32
frontend/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
TanStackRouterVite({
|
||||||
|
target: 'react',
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
routesDirectory: './src/routes',
|
||||||
|
generatedRouteTree: './node_modules/.cache/tanstack-router/routeTree.gen.ts',
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~': path.resolve(__dirname, './src'),
|
||||||
|
'~generated': path.resolve(__dirname, './node_modules/.cache'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'node_modules/.cache/dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
cacheDir: 'node_modules/.cache/vite',
|
||||||
|
})
|
||||||
25
package.json
25
package.json
@@ -6,9 +6,28 @@
|
|||||||
"author": "Marko Marković <okram@civokram.com>",
|
"author": "Marko Marković <okram@civokram.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ultra -r dev"
|
"dev": "pnpm --parallel --filter=./backend --filter=./frontend run dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"engines": {
|
||||||
"ultra-runner": "3.10.5"
|
"node": ">=22",
|
||||||
|
"pnpm": ">=10"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.33.2",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"argon2",
|
||||||
|
"sqlite3",
|
||||||
|
"sharp",
|
||||||
|
"esbuild",
|
||||||
|
"keyv",
|
||||||
|
"isolated-vm",
|
||||||
|
"protobufjs"
|
||||||
|
],
|
||||||
|
"peerDependencyRules": {
|
||||||
|
"allowedVersions": {
|
||||||
|
"@vitejs/plugin-vue>vite": "8",
|
||||||
|
"vite>esbuild": "0.26"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16675
pnpm-lock.yaml
generated
16675
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
1306
schema/dump.sql
1306
schema/dump.sql
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user