29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.direnv
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
17
.storybook/main.ts
Normal file
17
.storybook/main.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
"stories": [
|
||||
"../src/**/*.mdx",
|
||||
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
||||
],
|
||||
"addons": [
|
||||
"@chromatic-com/storybook",
|
||||
"@storybook/addon-vitest",
|
||||
"@storybook/addon-a11y",
|
||||
"@storybook/addon-docs",
|
||||
"@storybook/addon-onboarding"
|
||||
],
|
||||
"framework": "@storybook/react-vite"
|
||||
};
|
||||
export default config;
|
||||
36
.storybook/preview.tsx
Normal file
36
.storybook/preview.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Decorator, Preview } from '@storybook/react-vite';
|
||||
import { ThemeProvider } from '../src/components/theme-provider';
|
||||
import '../src/index.css';
|
||||
import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router';
|
||||
|
||||
const RouterDecorator: Decorator = (Story) => {
|
||||
const rootRoute = createRootRoute({ component: () => <Story /> });
|
||||
const routeTree = rootRoute;
|
||||
const router = createRouter({ routeTree });
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const ThemeDecorator: Decorator = (Story) => {
|
||||
return <ThemeProvider defaultTheme="dark"><Story /></ThemeProvider>;
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [RouterDecorator, ThemeDecorator],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo'
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
7
.storybook/vitest.setup.ts
Normal file
7
.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
|
||||
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||
import * as projectAnnotations from './preview';
|
||||
|
||||
// This is an important step to apply the right configuration when testing your stories.
|
||||
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||
18
.zed/settings.json
Normal file
18
.zed/settings.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"file_scan_exclusions": [
|
||||
"src/components/ui",
|
||||
".tanstack",
|
||||
"node_modules",
|
||||
"dist",
|
||||
|
||||
// default values below
|
||||
"**/.git",
|
||||
"**/.svn",
|
||||
"**/.hg",
|
||||
"**/CVS",
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
"**/.classpath",
|
||||
"**/.settings"
|
||||
]
|
||||
}
|
||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-02-18
|
||||
**Stack:** React 19, Vite, TypeScript, TanStack (Router, Query, Form), Tailwind CSS, Shadcn/UI
|
||||
|
||||
## OVERVIEW
|
||||
Modern React SPA using Schema-Driven Development (OpenAPI) and file-based routing.
|
||||
Container-first deployment (Caddy + Podman/Docker).
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── client/ # Generated API client (OpenAPI-TS)
|
||||
│ ├── components/ # UI & Feature components
|
||||
│ │ ├── events/ # [COMPLEX] Event system + KYC flow
|
||||
│ │ └── ui/ # Shadcn/UI base components
|
||||
│ ├── hooks/ # Custom hooks (Data & UI)
|
||||
│ ├── routes/ # TanStack Router file-based routing
|
||||
│ ├── stories/ # Storybook stories (Test source)
|
||||
│ └── main.tsx # Entry point (No App.tsx usage)
|
||||
├── Containerfile # Multi-stage build (Node -> Caddy)
|
||||
└── openapi-ts.config.ts # API generation config
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| **Routing** | `src/routes/` | File-based. `__root.tsx` is layout. `_workbenchLayout` is main app. |
|
||||
| **API/Data** | `src/client/` | Generated via `pnpm gen`. DO NOT EDIT manually. |
|
||||
| **Events/KYC** | `src/components/events/` | Complex container-view + Zustand state machine. |
|
||||
| **UI Components** | `src/components/ui/` | Shadcn/UI. Extended in `shadcn-io` subdir. |
|
||||
| **Testing** | `src/stories/` | Stories serve as tests via Vitest + Playwright. |
|
||||
|
||||
## CONVENTIONS
|
||||
- **Style**: Antfu ESLint. 2 spaces. Semicolons. Single quotes.
|
||||
- **Routing**: TanStack Router. Use `Link` component. File nesting determines URL.
|
||||
- **State**: Server state = TanStack Query. Local complex state = Zustand. Global UI = Context.
|
||||
- **Testing**: Create `.stories.tsx` for components. No separate `tests/` folder for units.
|
||||
- **Imports**: Use `@/` alias for `src/`.
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
- **Manual API Calls**: ALWAYS use generated hooks from `@/client`.
|
||||
- **Global State**: Avoid Redux/Zustand for server data. Use Query Cache.
|
||||
- **Dead Code**: `src/App.tsx` is unused. Ignore it.
|
||||
- **`any` Usage**: Strictly forbidden. Use `unknown` or fix generated types.
|
||||
- **Nesting**: Do not nest API logic inside `src/client` manually.
|
||||
|
||||
## COMMANDS
|
||||
```bash
|
||||
pnpm dev # Start Vite dev server
|
||||
pnpm build # TSC + Vite build
|
||||
pnpm gen # Regenerate API client (requires internal network)
|
||||
pnpm storybook # Start component workbench
|
||||
pnpm test # Run Vitest (headless browser)
|
||||
```
|
||||
|
||||
## NOTES
|
||||
- **Proxy**: API requests proxy to `http://10.8.0.11:8000` (VPN/Internal).
|
||||
- **Deployment**: Uses Caddy as web server in production container.
|
||||
- **Auth**: Handled via `useUserInfo` suspense query.
|
||||
9
Caddyfile
Normal file
9
Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
||||
:3000 {
|
||||
root * /srv
|
||||
encode zstd gzip
|
||||
|
||||
handle_path /app/* {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
16
Containerfile
Normal file
16
Containerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM docker.io/node:22-alpine AS client-cms-build
|
||||
RUN apk add just
|
||||
RUN npm install -g corepack && \
|
||||
corepack enable
|
||||
WORKDIR /app
|
||||
ARG VITE_APP_BASE_URL
|
||||
ENV VITE_APP_BASE_URL=$VITE_APP_BASE_URL
|
||||
COPY . .
|
||||
RUN pnpm install
|
||||
RUN pnpm run build --outDir /app/dist
|
||||
|
||||
FROM docker.io/caddy:2-alpine
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=client-cms-build /app/dist /srv
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
17
eslint.config.js
Normal file
17
eslint.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import antfu from '@antfu/eslint-config';
|
||||
import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||
|
||||
export default antfu({
|
||||
gitignore: true,
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*', 'src/client/**/*', 'openapi-ts.config.ts', 'vitest.shims.d.ts', '.storybook/**/*'],
|
||||
react: true,
|
||||
stylistic: {
|
||||
semi: true,
|
||||
quotes: 'single',
|
||||
indent: 2,
|
||||
},
|
||||
typescript: {
|
||||
tsconfigPath: 'tsconfig.json',
|
||||
},
|
||||
}, ...pluginQuery.configs['flat/recommended']);
|
||||
13
index.html
Normal file
13
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="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
openapi-ts.config.ts
Normal file
23
openapi-ts.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from '@hey-api/openapi-ts';
|
||||
|
||||
export default defineConfig({
|
||||
input: 'http://10.0.0.10:8000/swagger/doc.json', // sign up at app.heyapi.dev
|
||||
output: 'src/client',
|
||||
plugins: [
|
||||
'@hey-api/typescript',
|
||||
{
|
||||
name: '@tanstack/react-query',
|
||||
infiniteQueryOptions: true,
|
||||
infiniteQueryKeys: true,
|
||||
},
|
||||
'zod',
|
||||
{
|
||||
name: '@hey-api/transformers',
|
||||
dates: true,
|
||||
},
|
||||
{
|
||||
name: '@hey-api/sdk',
|
||||
transformer: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
131
package.json
Normal file
131
package.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"name": "cms-client",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"gen": "openapi-ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@dicebear/collection": "^9.3.1",
|
||||
"@dicebear/core": "^9.3.1",
|
||||
"@dicebear/identicon": "^9.3.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@marsidev/react-turnstile": "^1.4.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-form": "^1.27.7",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-router": "^1.141.6",
|
||||
"@tanstack/react-router-devtools": "^1.141.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/zod-adapter": "^1.143.4",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"@yudiel/react-qr-scanner": "^2.5.1",
|
||||
"axios": "^1.13.2",
|
||||
"base-64": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"culori": "^4.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"immer": "^11.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash-es": "^4.17.22",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-error-boundary": "^6.1.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-spinners": "^0.17.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"utf8": "^3.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^6.7.1",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@hey-api/openapi-ts": "0.91.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@storybook/addon-a11y": "^10.2.3",
|
||||
"@storybook/addon-docs": "^10.2.3",
|
||||
"@storybook/addon-onboarding": "^10.2.3",
|
||||
"@storybook/addon-themes": "^10.2.3",
|
||||
"@storybook/addon-vitest": "^10.2.3",
|
||||
"@storybook/react-vite": "^10.2.3",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/base-64": "^1.0.2",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-storybook": "^10.2.3",
|
||||
"globals": "^16.5.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"playwright": "^1.58.2",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"storybook": "^10.2.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"type-fest": "^5.4.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "bun run lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "eslint --fix"
|
||||
}
|
||||
}
|
||||
11366
pnpm-lock.yaml
generated
Normal file
11366
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
11
src/App.tsx
Normal file
11
src/App.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<p>Hello world</p>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { App };
|
||||
BIN
src/assets/event-placeholder.png
Normal file
BIN
src/assets/event-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
28
src/assets/nixos.svg
Normal file
28
src/assets/nixos.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M120.587 309.626L148.36 261.518L269.887 472.005H214.34L186.567 423.897L158.794 472.005H131.021L117.126 447.943L158.794 375.772L120.604 309.626H120.587Z" fill="url(#paint0_linear_2199_41)"/>
|
||||
<path d="M141.421 165.285H196.968L75.4412 375.772L47.6681 327.664L75.4412 279.556H19.8949L6 255.494L19.8949 231.432H103.231L141.421 165.285Z" fill="url(#paint1_linear_2199_41)"/>
|
||||
<path d="M276.826 111.17L304.599 159.278H61.5632L89.3364 111.17H144.883L117.11 63.0623L131.004 39H158.778L200.446 111.17H276.826Z" fill="url(#paint2_linear_2199_41)"/>
|
||||
<path d="M391.413 201.379L363.64 249.487L242.114 39H297.66L325.433 87.108L353.206 39H380.979L394.874 63.0623L353.206 135.233L391.396 201.379H391.413Z" fill="url(#paint3_linear_2199_41)"/>
|
||||
<path d="M370.579 345.703H315.032L436.559 135.216L464.332 183.324L436.559 231.432H492.105L506 255.494L492.105 279.556H408.769L370.579 345.703Z" fill="url(#paint4_linear_2199_41)"/>
|
||||
<path d="M235.175 399.835L207.401 351.727H450.454L422.681 399.835H367.134L394.908 447.943L381.013 472.005H353.24L311.572 399.835H235.191H235.175Z" fill="url(#paint5_linear_2199_41)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2199_41" x1="-244.299" y1="-121.183" x2="-163.239" y2="19.218" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2199_41" x1="-194.029" y1="-258.424" x2="-275.089" y2="-118.023" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2199_41" x1="-50.0423" y1="-283.509" x2="-212.164" y2="-283.509" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2199_41" x1="43.6727" y1="-171.37" x2="-37.3876" y2="-311.771" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_2199_41" x1="-6.58154" y1="-34.1292" x2="74.4793" y2="-174.531" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_2199_41" x1="-150.568" y1="-9.02725" x2="11.5536" y2="-9.02733" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
547
src/client/@tanstack/react-query.gen.ts
Normal file
547
src/client/@tanstack/react-query.gen.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
||||
|
||||
import { client } from '../client.gen';
|
||||
import { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventJoined, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAgendaSubmit, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from '../sdk.gen';
|
||||
import type { GetAuthRedirectData, GetAuthRedirectError, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceResponse, GetEventCheckinData, GetEventCheckinError, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryResponse, GetEventCheckinResponse, GetEventInfoData, GetEventInfoError, GetEventInfoResponse, GetEventJoinedData, GetEventJoinedError, GetEventJoinedResponse, GetEventListData, GetEventListError, GetEventListResponse, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, GetUserInfoData, GetUserInfoError, GetUserInfoResponse, GetUserListData, GetUserListError, GetUserListResponse, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateResponse, PostAgendaSubmitData, PostAgendaSubmitError, PostAgendaSubmitResponse, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeResponse, PostAuthMagicData, PostAuthMagicError, PostAuthMagicResponse, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshResponse, PostAuthTokenData, PostAuthTokenError, PostAuthTokenResponse, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitResponse, PostEventJoinData, PostEventJoinError, PostEventJoinResponse, PostKycQueryData, PostKycQueryError, PostKycQueryResponse, PostKycSessionData, PostKycSessionError, PostKycSessionResponse } from '../types.gen';
|
||||
|
||||
/**
|
||||
* Submit Agenda
|
||||
*
|
||||
* Creates a new agenda item for a specific attendance record.
|
||||
*/
|
||||
export const postAgendaSubmitMutation = (options?: Partial<Options<PostAgendaSubmitData>>): UseMutationOptions<PostAgendaSubmitResponse, PostAgendaSubmitError, Options<PostAgendaSubmitData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostAgendaSubmitResponse, PostAgendaSubmitError, Options<PostAgendaSubmitData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postAgendaSubmit({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange Auth Code
|
||||
*
|
||||
* Exchanges client credentials and user session for a specific redirect authorization code.
|
||||
*/
|
||||
export const postAuthExchangeMutation = (options?: Partial<Options<PostAuthExchangeData>>): UseMutationOptions<PostAuthExchangeResponse, PostAuthExchangeError, Options<PostAuthExchangeData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostAuthExchangeResponse, PostAuthExchangeError, Options<PostAuthExchangeData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postAuthExchange({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request Magic Link
|
||||
*
|
||||
* Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
|
||||
*/
|
||||
export const postAuthMagicMutation = (options?: Partial<Options<PostAuthMagicData>>): UseMutationOptions<PostAuthMagicResponse, PostAuthMagicError, Options<PostAuthMagicData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostAuthMagicResponse, PostAuthMagicError, Options<PostAuthMagicData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postAuthMagic({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export type QueryKey<TOptions extends Options> = [
|
||||
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
|
||||
_id: string;
|
||||
_infinite?: boolean;
|
||||
tags?: ReadonlyArray<string>;
|
||||
}
|
||||
];
|
||||
|
||||
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean, tags?: ReadonlyArray<string>): [
|
||||
QueryKey<TOptions>[0]
|
||||
] => {
|
||||
const params: QueryKey<TOptions>[0] = { _id: id, baseUrl: options?.baseUrl || (options?.client ?? client).getConfig().baseUrl } as QueryKey<TOptions>[0];
|
||||
if (infinite) {
|
||||
params._infinite = infinite;
|
||||
}
|
||||
if (tags) {
|
||||
params.tags = tags;
|
||||
}
|
||||
if (options?.body) {
|
||||
params.body = options.body;
|
||||
}
|
||||
if (options?.headers) {
|
||||
params.headers = options.headers;
|
||||
}
|
||||
if (options?.path) {
|
||||
params.path = options.path;
|
||||
}
|
||||
if (options?.query) {
|
||||
params.query = options.query;
|
||||
}
|
||||
return [params];
|
||||
};
|
||||
|
||||
export const getAuthRedirectQueryKey = (options: Options<GetAuthRedirectData>) => createQueryKey('getAuthRedirect', options);
|
||||
|
||||
/**
|
||||
* Handle Auth Callback and Redirect
|
||||
*
|
||||
* Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
|
||||
*/
|
||||
export const getAuthRedirectOptions = (options: Options<GetAuthRedirectData>) => queryOptions<unknown, GetAuthRedirectError, unknown, ReturnType<typeof getAuthRedirectQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getAuthRedirect({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getAuthRedirectQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh Access Token
|
||||
*
|
||||
* Accepts a valid refresh token to issue a new access token and a rotated refresh token.
|
||||
*/
|
||||
export const postAuthRefreshMutation = (options?: Partial<Options<PostAuthRefreshData>>): UseMutationOptions<PostAuthRefreshResponse, PostAuthRefreshError, Options<PostAuthRefreshData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostAuthRefreshResponse, PostAuthRefreshError, Options<PostAuthRefreshData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postAuthRefresh({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange Code for Token
|
||||
*
|
||||
* Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
|
||||
*/
|
||||
export const postAuthTokenMutation = (options?: Partial<Options<PostAuthTokenData>>): UseMutationOptions<PostAuthTokenResponse, PostAuthTokenError, Options<PostAuthTokenData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostAuthTokenResponse, PostAuthTokenError, Options<PostAuthTokenData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postAuthToken({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getEventAttendanceQueryKey = (options: Options<GetEventAttendanceData>) => createQueryKey('getEventAttendance', options);
|
||||
|
||||
/**
|
||||
* Get Attendance List
|
||||
*
|
||||
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
|
||||
*/
|
||||
export const getEventAttendanceOptions = (options: Options<GetEventAttendanceData>) => queryOptions<GetEventAttendanceResponse, GetEventAttendanceError, GetEventAttendanceResponse, ReturnType<typeof getEventAttendanceQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventAttendance({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventAttendanceQueryKey(options)
|
||||
});
|
||||
|
||||
export const getEventCheckinQueryKey = (options: Options<GetEventCheckinData>) => createQueryKey('getEventCheckin', options);
|
||||
|
||||
/**
|
||||
* Generate Check-in Code
|
||||
*
|
||||
* Creates a temporary check-in code for the authenticated user and event.
|
||||
*/
|
||||
export const getEventCheckinOptions = (options: Options<GetEventCheckinData>) => queryOptions<GetEventCheckinResponse, GetEventCheckinError, GetEventCheckinResponse, ReturnType<typeof getEventCheckinQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventCheckin({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventCheckinQueryKey(options)
|
||||
});
|
||||
|
||||
export const getEventCheckinQueryQueryKey = (options: Options<GetEventCheckinQueryData>) => createQueryKey('getEventCheckinQuery', options);
|
||||
|
||||
/**
|
||||
* Query Check-in Status
|
||||
*
|
||||
* Returns the timestamp of when the user checked in, or null if not yet checked in.
|
||||
*/
|
||||
export const getEventCheckinQueryOptions = (options: Options<GetEventCheckinQueryData>) => queryOptions<GetEventCheckinQueryResponse, GetEventCheckinQueryError, GetEventCheckinQueryResponse, ReturnType<typeof getEventCheckinQueryQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventCheckinQuery({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventCheckinQueryQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Submit Check-in Code
|
||||
*
|
||||
* Submits the generated code to mark the user as attended.
|
||||
*/
|
||||
export const postEventCheckinSubmitMutation = (options?: Partial<Options<PostEventCheckinSubmitData>>): UseMutationOptions<PostEventCheckinSubmitResponse, PostEventCheckinSubmitError, Options<PostEventCheckinSubmitData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostEventCheckinSubmitResponse, PostEventCheckinSubmitError, Options<PostEventCheckinSubmitData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postEventCheckinSubmit({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getEventInfoQueryKey = (options: Options<GetEventInfoData>) => createQueryKey('getEventInfo', options);
|
||||
|
||||
/**
|
||||
* Get Event Information
|
||||
*
|
||||
* Fetches the name, start time, and end time of an event using its UUID.
|
||||
*/
|
||||
export const getEventInfoOptions = (options: Options<GetEventInfoData>) => queryOptions<GetEventInfoResponse, GetEventInfoError, GetEventInfoResponse, ReturnType<typeof getEventInfoQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventInfo({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventInfoQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Join an Event
|
||||
*
|
||||
* Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
|
||||
*/
|
||||
export const postEventJoinMutation = (options?: Partial<Options<PostEventJoinData>>): UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostEventJoinResponse, PostEventJoinError, Options<PostEventJoinData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postEventJoin({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getEventJoinedQueryKey = (options?: Options<GetEventJoinedData>) => createQueryKey('getEventJoined', options);
|
||||
|
||||
/**
|
||||
* Get Joined Events
|
||||
*
|
||||
* Fetches a list of events where the authenticated user is a participant. Supports pagination.
|
||||
*/
|
||||
export const getEventJoinedOptions = (options?: Options<GetEventJoinedData>) => queryOptions<GetEventJoinedResponse, GetEventJoinedError, GetEventJoinedResponse, ReturnType<typeof getEventJoinedQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventJoined({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventJoinedQueryKey(options)
|
||||
});
|
||||
|
||||
const createInfiniteParams = <K extends Pick<QueryKey<Options>[0], 'body' | 'headers' | 'path' | 'query'>>(queryKey: QueryKey<Options>, page: K) => {
|
||||
const params = { ...queryKey[0] };
|
||||
if (page.body) {
|
||||
params.body = {
|
||||
...queryKey[0].body as any,
|
||||
...page.body as any
|
||||
};
|
||||
}
|
||||
if (page.headers) {
|
||||
params.headers = {
|
||||
...queryKey[0].headers,
|
||||
...page.headers
|
||||
};
|
||||
}
|
||||
if (page.path) {
|
||||
params.path = {
|
||||
...queryKey[0].path as any,
|
||||
...page.path as any
|
||||
};
|
||||
}
|
||||
if (page.query) {
|
||||
params.query = {
|
||||
...queryKey[0].query as any,
|
||||
...page.query as any
|
||||
};
|
||||
}
|
||||
return params as unknown as typeof page;
|
||||
};
|
||||
|
||||
export const getEventJoinedInfiniteQueryKey = (options?: Options<GetEventJoinedData>): QueryKey<Options<GetEventJoinedData>> => createQueryKey('getEventJoined', options, true);
|
||||
|
||||
/**
|
||||
* Get Joined Events
|
||||
*
|
||||
* Fetches a list of events where the authenticated user is a participant. Supports pagination.
|
||||
*/
|
||||
export const getEventJoinedInfiniteOptions = (options?: Options<GetEventJoinedData>) => infiniteQueryOptions<GetEventJoinedResponse, GetEventJoinedError, InfiniteData<GetEventJoinedResponse>, QueryKey<Options<GetEventJoinedData>>, number | Pick<QueryKey<Options<GetEventJoinedData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||
// @ts-ignore
|
||||
{
|
||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||
// @ts-ignore
|
||||
const page: Pick<QueryKey<Options<GetEventJoinedData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
|
||||
query: {
|
||||
offset: pageParam
|
||||
}
|
||||
};
|
||||
const params = createInfiniteParams(queryKey, page);
|
||||
const { data } = await getEventJoined({
|
||||
...options,
|
||||
...params,
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventJoinedInfiniteQueryKey(options)
|
||||
});
|
||||
|
||||
export const getEventListQueryKey = (options?: Options<GetEventListData>) => createQueryKey('getEventList', options);
|
||||
|
||||
/**
|
||||
* List Events
|
||||
*
|
||||
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
|
||||
*/
|
||||
export const getEventListOptions = (options?: Options<GetEventListData>) => queryOptions<GetEventListResponse, GetEventListError, GetEventListResponse, ReturnType<typeof getEventListQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getEventList({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventListQueryKey(options)
|
||||
});
|
||||
|
||||
export const getEventListInfiniteQueryKey = (options?: Options<GetEventListData>): QueryKey<Options<GetEventListData>> => createQueryKey('getEventList', options, true);
|
||||
|
||||
/**
|
||||
* List Events
|
||||
*
|
||||
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
|
||||
*/
|
||||
export const getEventListInfiniteOptions = (options?: Options<GetEventListData>) => infiniteQueryOptions<GetEventListResponse, GetEventListError, InfiniteData<GetEventListResponse>, QueryKey<Options<GetEventListData>>, number | Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||
// @ts-ignore
|
||||
{
|
||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||
// @ts-ignore
|
||||
const page: Pick<QueryKey<Options<GetEventListData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
|
||||
query: {
|
||||
offset: pageParam
|
||||
}
|
||||
};
|
||||
const params = createInfiniteParams(queryKey, page);
|
||||
const { data } = await getEventList({
|
||||
...options,
|
||||
...params,
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getEventListInfiniteQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Query KYC Status
|
||||
*
|
||||
* Checks the current state of a KYC session and updates local database if approved.
|
||||
*/
|
||||
export const postKycQueryMutation = (options?: Partial<Options<PostKycQueryData>>): UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostKycQueryResponse, PostKycQueryError, Options<PostKycQueryData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postKycQuery({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create KYC Session
|
||||
*
|
||||
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
|
||||
*/
|
||||
export const postKycSessionMutation = (options?: Partial<Options<PostKycSessionData>>): UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> => {
|
||||
const mutationOptions: UseMutationOptions<PostKycSessionResponse, PostKycSessionError, Options<PostKycSessionData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await postKycSession({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getUserInfoQueryKey = (options?: Options<GetUserInfoData>) => createQueryKey('getUserInfo', options);
|
||||
|
||||
/**
|
||||
* Get My User Information
|
||||
*
|
||||
* Fetches the complete profile data for the user associated with the provided session/token.
|
||||
*/
|
||||
export const getUserInfoOptions = (options?: Options<GetUserInfoData>) => queryOptions<GetUserInfoResponse, GetUserInfoError, GetUserInfoResponse, ReturnType<typeof getUserInfoQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getUserInfo({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getUserInfoQueryKey(options)
|
||||
});
|
||||
|
||||
export const getUserInfoByUserIdQueryKey = (options: Options<GetUserInfoByUserIdData>) => createQueryKey('getUserInfoByUserId', options);
|
||||
|
||||
/**
|
||||
* Get Other User Information
|
||||
*
|
||||
* Fetches the complete profile data for the user associated with the provided session/token.
|
||||
*/
|
||||
export const getUserInfoByUserIdOptions = (options: Options<GetUserInfoByUserIdData>) => queryOptions<GetUserInfoByUserIdResponse, GetUserInfoByUserIdError, GetUserInfoByUserIdResponse, ReturnType<typeof getUserInfoByUserIdQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getUserInfoByUserId({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getUserInfoByUserIdQueryKey(options)
|
||||
});
|
||||
|
||||
export const getUserListQueryKey = (options: Options<GetUserListData>) => createQueryKey('getUserList', options);
|
||||
|
||||
/**
|
||||
* List Users
|
||||
*
|
||||
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
|
||||
*/
|
||||
export const getUserListOptions = (options: Options<GetUserListData>) => queryOptions<GetUserListResponse, GetUserListError, GetUserListResponse, ReturnType<typeof getUserListQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getUserList({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getUserListQueryKey(options)
|
||||
});
|
||||
|
||||
export const getUserListInfiniteQueryKey = (options: Options<GetUserListData>): QueryKey<Options<GetUserListData>> => createQueryKey('getUserList', options, true);
|
||||
|
||||
/**
|
||||
* List Users
|
||||
*
|
||||
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
|
||||
*/
|
||||
export const getUserListInfiniteOptions = (options: Options<GetUserListData>) => infiniteQueryOptions<GetUserListResponse, GetUserListError, InfiniteData<GetUserListResponse>, QueryKey<Options<GetUserListData>>, string | Pick<QueryKey<Options<GetUserListData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||
// @ts-ignore
|
||||
{
|
||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||
// @ts-ignore
|
||||
const page: Pick<QueryKey<Options<GetUserListData>>[0], 'body' | 'headers' | 'path' | 'query'> = typeof pageParam === 'object' ? pageParam : {
|
||||
query: {
|
||||
offset: pageParam
|
||||
}
|
||||
};
|
||||
const params = createInfiniteParams(queryKey, page);
|
||||
const { data } = await getUserList({
|
||||
...options,
|
||||
...params,
|
||||
signal,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getUserListInfiniteQueryKey(options)
|
||||
});
|
||||
|
||||
/**
|
||||
* Update User Information
|
||||
*
|
||||
* Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
|
||||
* Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
|
||||
*/
|
||||
export const patchUserUpdateMutation = (options?: Partial<Options<PatchUserUpdateData>>): UseMutationOptions<PatchUserUpdateResponse, PatchUserUpdateError, Options<PatchUserUpdateData>> => {
|
||||
const mutationOptions: UseMutationOptions<PatchUserUpdateResponse, PatchUserUpdateError, Options<PatchUserUpdateData>> = {
|
||||
mutationFn: async (fnOptions) => {
|
||||
const { data } = await patchUserUpdate({
|
||||
...options,
|
||||
...fnOptions,
|
||||
throwOnError: true
|
||||
});
|
||||
return data;
|
||||
}
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
31
src/client/AGENTS.md
Normal file
31
src/client/AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# API CLIENT KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
This directory contains the auto-generated API client. It uses `@hey-api/openapi-ts` to transform the backend OpenAPI schema into type-safe TypeScript SDKs and TanStack Query hooks. This ensures frontend-backend alignment without manual interface maintenance.
|
||||
|
||||
## STRUCTURE
|
||||
- **`@tanstack/`**: Contains React Query hooks (`useQuery`, `useMutation`), query keys, and infinite query options. Use these for most data fetching.
|
||||
- **`core/`**: Houses the internal request engine, interceptors, and base configuration.
|
||||
- **`sdk.gen.ts`**: The primary SDK entry point. Contains raw async functions for every API endpoint.
|
||||
- **`types.gen.ts`**: All TypeScript interfaces, enums, and types derived from the schema.
|
||||
- **`zod.gen.ts`**: Zod schemas for every API model. Useful for TanStack Form validation.
|
||||
- **`client.gen.ts`**: The generated client instance configuration.
|
||||
- **`index.ts`**: The main entry point that exports the SDK and types.
|
||||
|
||||
## INTEGRATION
|
||||
- **Data Fetching**: Prefer the hooks in `@tanstack/` over direct SDK calls to benefit from caching and loading states.
|
||||
- **Form Validation**: Import Zod schemas from `zod.gen.ts` to keep frontend validation in sync with backend constraints.
|
||||
- **Error Handling**: Use the `ApiError` type from `core/` to handle specific backend error responses in catch blocks.
|
||||
|
||||
## WORKFLOW
|
||||
Regenerate the client whenever the backend schema changes:
|
||||
1. Ensure you are connected to the internal network (VPN).
|
||||
2. Execute `pnpm gen` from the project root.
|
||||
3. Review the diff to ensure no breaking changes were introduced.
|
||||
|
||||
The generator pulls the latest schema from `http://10.0.0.10:8000/swagger/doc.json`. Access to this IP is mandatory for successful regeneration.
|
||||
|
||||
## WARNINGS
|
||||
- **DO NOT EDIT MANUALLY**: This directory is a "black box" for manual edits. Any changes will be overwritten during the next generation cycle.
|
||||
- **Schema Sync**: If you encounter type errors in components, the client is likely out of sync with the backend. Run `pnpm gen` first.
|
||||
- **Network Dependency**: Regeneration will fail if the internal API documentation server is offline or unreachable.
|
||||
16
src/client/client.gen.ts
Normal file
16
src/client/client.gen.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://localhost:8000/app/api/v1' }));
|
||||
311
src/client/client/client.gen.ts
Normal file
311
src/client/client/client.gen.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type {
|
||||
Client,
|
||||
Config,
|
||||
RequestOptions,
|
||||
ResolvedRequestOptions,
|
||||
} from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<
|
||||
Request,
|
||||
Response,
|
||||
unknown,
|
||||
ResolvedRequestOptions
|
||||
>();
|
||||
|
||||
const beforeRequest = async (options: RequestOptions) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const url = buildUrl(opts);
|
||||
|
||||
return { opts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
// @ts-expect-error
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(
|
||||
error,
|
||||
undefined as any,
|
||||
request,
|
||||
opts,
|
||||
)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (
|
||||
response.status === 204 ||
|
||||
response.headers.get('Content-Length') === '0'
|
||||
) {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'json': {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn =
|
||||
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn =
|
||||
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as
|
||||
| BodyInit
|
||||
| null
|
||||
| undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
25
src/client/client/index.ts
Normal file
25
src/client/client/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
241
src/client/client/types.gen.ts
Normal file
241
src/client/client/types.gen.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type {
|
||||
Client as CoreClient,
|
||||
Config as CoreConfig,
|
||||
} from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
|
||||
CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?:
|
||||
| 'arrayBuffer'
|
||||
| 'auto'
|
||||
| 'blob'
|
||||
| 'formData'
|
||||
| 'json'
|
||||
| 'stream'
|
||||
| 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
?
|
||||
| (TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData)
|
||||
| undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown>
|
||||
? TError[keyof TError]
|
||||
: TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<
|
||||
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
|
||||
'method'
|
||||
>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<
|
||||
RequestFn,
|
||||
Config,
|
||||
MethodFn,
|
||||
BuildUrlFn,
|
||||
SseFn
|
||||
> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
332
src/client/client/utils.gen.ts
Normal file
332
src/client/client/utils.gen.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (
|
||||
contentType: string | null,
|
||||
): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cleanContent.startsWith('application/json') ||
|
||||
cleanContent.endsWith('+json')
|
||||
) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) =>
|
||||
cleanContent.startsWith(type),
|
||||
)
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator =
|
||||
header instanceof Headers
|
||||
? headersEntries(header)
|
||||
: Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e. their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(
|
||||
id: number | Interceptor,
|
||||
fn: Interceptor,
|
||||
): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
42
src/client/core/auth.gen.ts
Normal file
42
src/client/core/auth.gen.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token =
|
||||
typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
100
src/client/core/bodySerializer.gen.ts
Normal file
100
src/client/core/bodySerializer.gen.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type {
|
||||
ArrayStyle,
|
||||
ObjectStyle,
|
||||
SerializerOptions,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: any) => any;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (
|
||||
data: FormData,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (
|
||||
data: URLSearchParams,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: <T>(body: T): string =>
|
||||
JSON.stringify(body, (_key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value,
|
||||
),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
||||
body: T,
|
||||
): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
176
src/client/core/params.gen.ts
Normal file
176
src/client/core/params.gen.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (
|
||||
args: ReadonlyArray<unknown>,
|
||||
fields: FieldsConfig,
|
||||
) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) =>
|
||||
key.startsWith(prefix),
|
||||
);
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[
|
||||
key.slice(prefix.length)
|
||||
] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
181
src/client/core/pathSerializer.gen.ts
Normal file
181
src/client/core/pathSerializer.gen.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T>
|
||||
extends SerializePrimitiveOptions,
|
||||
SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [
|
||||
...values,
|
||||
key,
|
||||
allowReserved ? (v as string) : encodeURIComponent(v as string),
|
||||
];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix'
|
||||
? separator + joinedValues
|
||||
: joinedValues;
|
||||
};
|
||||
136
src/client/core/queryKeySerializer.gen.ts
Normal file
136
src/client/core/queryKeySerializer.gen.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (
|
||||
value: unknown,
|
||||
): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (
|
||||
value === undefined ||
|
||||
typeof value === 'function' ||
|
||||
typeof value === 'symbol'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof URLSearchParams !== 'undefined' &&
|
||||
value instanceof URLSearchParams
|
||||
) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
266
src/client/core/serverSentEvents.gen.ts
Normal file
266
src/client/core/serverSentEvents.gen.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<
|
||||
RequestInit,
|
||||
'method'
|
||||
> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<
|
||||
TData = unknown,
|
||||
TReturn = void,
|
||||
TNext = unknown,
|
||||
> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep =
|
||||
sseSleepFn ??
|
||||
((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`SSE failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(
|
||||
line.replace(/^retry:\s*/, ''),
|
||||
10,
|
||||
);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (
|
||||
sseMaxRetryAttempts !== undefined &&
|
||||
attempt >= sseMaxRetryAttempts
|
||||
) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(
|
||||
retryDelay * 2 ** (attempt - 1),
|
||||
sseMaxRetryDelay ?? 30000,
|
||||
);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
};
|
||||
118
src/client/core/types.gen.ts
Normal file
118
src/client/core/types.gen.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type {
|
||||
BodySerializer,
|
||||
QuerySerializer,
|
||||
QuerySerializerOptions,
|
||||
} from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never]
|
||||
? { sse?: never }
|
||||
: { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| (string | number | boolean)[]
|
||||
| null
|
||||
| undefined
|
||||
| unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g. converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
|
||||
? never
|
||||
: K]: T[K];
|
||||
};
|
||||
143
src/client/core/utils.gen.ts
Normal file
143
src/client/core/utils.gen.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeArrayParam({ explode, name, style, value }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e. client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
4
src/client/index.ts
Normal file
4
src/client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export { getAuthRedirect, getEventAttendance, getEventCheckin, getEventCheckinQuery, getEventInfo, getEventJoined, getEventList, getUserInfo, getUserInfoByUserId, getUserList, type Options, patchUserUpdate, postAgendaSubmit, postAuthExchange, postAuthMagic, postAuthRefresh, postAuthToken, postEventCheckinSubmit, postEventJoin, postKycQuery, postKycSession } from './sdk.gen';
|
||||
export type { ClientOptions, DataEventIndexDoc, DataUserIndexDoc, GetAuthRedirectData, GetAuthRedirectError, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceError, GetEventAttendanceErrors, GetEventAttendanceResponse, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinError, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryError, GetEventCheckinQueryErrors, GetEventCheckinQueryResponse, GetEventCheckinQueryResponses, GetEventCheckinResponse, GetEventCheckinResponses, GetEventInfoData, GetEventInfoError, GetEventInfoErrors, GetEventInfoResponse, GetEventInfoResponses, GetEventJoinedData, GetEventJoinedError, GetEventJoinedErrors, GetEventJoinedResponse, GetEventJoinedResponses, GetEventListData, GetEventListError, GetEventListErrors, GetEventListResponse, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdError, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponse, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoError, GetUserInfoErrors, GetUserInfoResponse, GetUserInfoResponses, GetUserListData, GetUserListError, GetUserListErrors, GetUserListResponse, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateError, PatchUserUpdateErrors, PatchUserUpdateResponse, PatchUserUpdateResponses, PostAgendaSubmitData, PostAgendaSubmitError, PostAgendaSubmitErrors, PostAgendaSubmitResponse, PostAgendaSubmitResponses, PostAuthExchangeData, PostAuthExchangeError, PostAuthExchangeErrors, PostAuthExchangeResponse, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicError, PostAuthMagicErrors, PostAuthMagicResponse, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshError, PostAuthRefreshErrors, PostAuthRefreshResponse, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenError, PostAuthTokenErrors, PostAuthTokenResponse, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitError, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponse, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinError, PostEventJoinErrors, PostEventJoinResponse, PostEventJoinResponses, PostKycQueryData, PostKycQueryError, PostKycQueryErrors, PostKycQueryResponse, PostKycQueryResponses, PostKycSessionData, PostKycSessionError, PostKycSessionErrors, PostKycSessionResponse, PostKycSessionResponses, ServiceAgendaSubmitData, ServiceAgendaSubmitResponse, ServiceAuthExchangeData, ServiceAuthExchangeResponse, ServiceAuthMagicData, ServiceAuthMagicResponse, ServiceAuthRefreshData, ServiceAuthTokenData, ServiceAuthTokenResponse, ServiceEventAttendanceListResponse, ServiceEventCheckinQueryResponse, ServiceEventCheckinResponse, ServiceEventCheckinSubmitData, ServiceEventEventJoinData, ServiceEventEventJoinResponse, ServiceKycKycQueryData, ServiceKycKycQueryResponse, ServiceKycKycSessionData, ServiceKycKycSessionResponse, ServiceUserUserInfoData, UtilsRespStatus } from './types.gen';
|
||||
230
src/client/sdk.gen.ts
Normal file
230
src/client/sdk.gen.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Client, Options as Options2, TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { GetAuthRedirectData, GetAuthRedirectErrors, GetEventAttendanceData, GetEventAttendanceErrors, GetEventAttendanceResponses, GetEventCheckinData, GetEventCheckinErrors, GetEventCheckinQueryData, GetEventCheckinQueryErrors, GetEventCheckinQueryResponses, GetEventCheckinResponses, GetEventInfoData, GetEventInfoErrors, GetEventInfoResponses, GetEventJoinedData, GetEventJoinedErrors, GetEventJoinedResponses, GetEventListData, GetEventListErrors, GetEventListResponses, GetUserInfoByUserIdData, GetUserInfoByUserIdErrors, GetUserInfoByUserIdResponses, GetUserInfoData, GetUserInfoErrors, GetUserInfoResponses, GetUserListData, GetUserListErrors, GetUserListResponses, PatchUserUpdateData, PatchUserUpdateErrors, PatchUserUpdateResponses, PostAgendaSubmitData, PostAgendaSubmitErrors, PostAgendaSubmitResponses, PostAuthExchangeData, PostAuthExchangeErrors, PostAuthExchangeResponses, PostAuthMagicData, PostAuthMagicErrors, PostAuthMagicResponses, PostAuthRefreshData, PostAuthRefreshErrors, PostAuthRefreshResponses, PostAuthTokenData, PostAuthTokenErrors, PostAuthTokenResponses, PostEventCheckinSubmitData, PostEventCheckinSubmitErrors, PostEventCheckinSubmitResponses, PostEventJoinData, PostEventJoinErrors, PostEventJoinResponses, PostKycQueryData, PostKycQueryErrors, PostKycQueryResponses, PostKycSessionData, PostKycSessionErrors, PostKycSessionResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||
/**
|
||||
* You can provide a client instance returned by `createClient()` instead of
|
||||
* individual options. This might be also useful if you want to implement a
|
||||
* custom client.
|
||||
*/
|
||||
client?: Client;
|
||||
/**
|
||||
* You can pass arbitrary values through the `meta` object. This can be
|
||||
* used to access values that aren't defined as part of the SDK function.
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Submit Agenda
|
||||
*
|
||||
* Creates a new agenda item for a specific attendance record.
|
||||
*/
|
||||
export const postAgendaSubmit = <ThrowOnError extends boolean = false>(options: Options<PostAgendaSubmitData, ThrowOnError>) => (options.client ?? client).post<PostAgendaSubmitResponses, PostAgendaSubmitErrors, ThrowOnError>({
|
||||
url: '/agenda/submit',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Exchange Auth Code
|
||||
*
|
||||
* Exchanges client credentials and user session for a specific redirect authorization code.
|
||||
*/
|
||||
export const postAuthExchange = <ThrowOnError extends boolean = false>(options: Options<PostAuthExchangeData, ThrowOnError>) => (options.client ?? client).post<PostAuthExchangeResponses, PostAuthExchangeErrors, ThrowOnError>({
|
||||
url: '/auth/exchange',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Request Magic Link
|
||||
*
|
||||
* Verifies Turnstile token and sends an authentication link via email. Returns the URI directly if debug mode is enabled.
|
||||
*/
|
||||
export const postAuthMagic = <ThrowOnError extends boolean = false>(options: Options<PostAuthMagicData, ThrowOnError>) => (options.client ?? client).post<PostAuthMagicResponses, PostAuthMagicErrors, ThrowOnError>({
|
||||
url: '/auth/magic',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle Auth Callback and Redirect
|
||||
*
|
||||
* Verifies the temporary email code, ensures the user exists (or creates one), validates the client's redirect URI, and finally performs a 302 redirect with a new authorization code.
|
||||
*/
|
||||
export const getAuthRedirect = <ThrowOnError extends boolean = false>(options: Options<GetAuthRedirectData, ThrowOnError>) => (options.client ?? client).get<unknown, GetAuthRedirectErrors, ThrowOnError>({ url: '/auth/redirect', ...options });
|
||||
|
||||
/**
|
||||
* Refresh Access Token
|
||||
*
|
||||
* Accepts a valid refresh token to issue a new access token and a rotated refresh token.
|
||||
*/
|
||||
export const postAuthRefresh = <ThrowOnError extends boolean = false>(options: Options<PostAuthRefreshData, ThrowOnError>) => (options.client ?? client).post<PostAuthRefreshResponses, PostAuthRefreshErrors, ThrowOnError>({
|
||||
url: '/auth/refresh',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Exchange Code for Token
|
||||
*
|
||||
* Verifies the provided authorization code and issues a pair of JWT tokens (Access and Refresh).
|
||||
*/
|
||||
export const postAuthToken = <ThrowOnError extends boolean = false>(options: Options<PostAuthTokenData, ThrowOnError>) => (options.client ?? client).post<PostAuthTokenResponses, PostAuthTokenErrors, ThrowOnError>({
|
||||
url: '/auth/token',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get Attendance List
|
||||
*
|
||||
* Retrieves the list of attendees, including user info and decrypted KYC data for a specified event.
|
||||
*/
|
||||
export const getEventAttendance = <ThrowOnError extends boolean = false>(options: Options<GetEventAttendanceData, ThrowOnError>) => (options.client ?? client).get<GetEventAttendanceResponses, GetEventAttendanceErrors, ThrowOnError>({ url: '/event/attendance', ...options });
|
||||
|
||||
/**
|
||||
* Generate Check-in Code
|
||||
*
|
||||
* Creates a temporary check-in code for the authenticated user and event.
|
||||
*/
|
||||
export const getEventCheckin = <ThrowOnError extends boolean = false>(options: Options<GetEventCheckinData, ThrowOnError>) => (options.client ?? client).get<GetEventCheckinResponses, GetEventCheckinErrors, ThrowOnError>({ url: '/event/checkin', ...options });
|
||||
|
||||
/**
|
||||
* Query Check-in Status
|
||||
*
|
||||
* Returns the timestamp of when the user checked in, or null if not yet checked in.
|
||||
*/
|
||||
export const getEventCheckinQuery = <ThrowOnError extends boolean = false>(options: Options<GetEventCheckinQueryData, ThrowOnError>) => (options.client ?? client).get<GetEventCheckinQueryResponses, GetEventCheckinQueryErrors, ThrowOnError>({ url: '/event/checkin/query', ...options });
|
||||
|
||||
/**
|
||||
* Submit Check-in Code
|
||||
*
|
||||
* Submits the generated code to mark the user as attended.
|
||||
*/
|
||||
export const postEventCheckinSubmit = <ThrowOnError extends boolean = false>(options: Options<PostEventCheckinSubmitData, ThrowOnError>) => (options.client ?? client).post<PostEventCheckinSubmitResponses, PostEventCheckinSubmitErrors, ThrowOnError>({
|
||||
url: '/event/checkin/submit',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get Event Information
|
||||
*
|
||||
* Fetches the name, start time, and end time of an event using its UUID.
|
||||
*/
|
||||
export const getEventInfo = <ThrowOnError extends boolean = false>(options: Options<GetEventInfoData, ThrowOnError>) => (options.client ?? client).get<GetEventInfoResponses, GetEventInfoErrors, ThrowOnError>({ url: '/event/info', ...options });
|
||||
|
||||
/**
|
||||
* Join an Event
|
||||
*
|
||||
* Allows an authenticated user to join an event by providing the event ID. The user's role and state are initialized by the service.
|
||||
*/
|
||||
export const postEventJoin = <ThrowOnError extends boolean = false>(options: Options<PostEventJoinData, ThrowOnError>) => (options.client ?? client).post<PostEventJoinResponses, PostEventJoinErrors, ThrowOnError>({
|
||||
url: '/event/join',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get Joined Events
|
||||
*
|
||||
* Fetches a list of events where the authenticated user is a participant. Supports pagination.
|
||||
*/
|
||||
export const getEventJoined = <ThrowOnError extends boolean = false>(options?: Options<GetEventJoinedData, ThrowOnError>) => (options?.client ?? client).get<GetEventJoinedResponses, GetEventJoinedErrors, ThrowOnError>({ url: '/event/joined', ...options });
|
||||
|
||||
/**
|
||||
* List Events
|
||||
*
|
||||
* Fetches a list of events with support for pagination via limit and offset. Data is retrieved directly from the database for consistency.
|
||||
*/
|
||||
export const getEventList = <ThrowOnError extends boolean = false>(options?: Options<GetEventListData, ThrowOnError>) => (options?.client ?? client).get<GetEventListResponses, GetEventListErrors, ThrowOnError>({ url: '/event/list', ...options });
|
||||
|
||||
/**
|
||||
* Query KYC Status
|
||||
*
|
||||
* Checks the current state of a KYC session and updates local database if approved.
|
||||
*/
|
||||
export const postKycQuery = <ThrowOnError extends boolean = false>(options: Options<PostKycQueryData, ThrowOnError>) => (options.client ?? client).post<PostKycQueryResponses, PostKycQueryErrors, ThrowOnError>({
|
||||
url: '/kyc/query',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create KYC Session
|
||||
*
|
||||
* Initializes a KYC process (CNRid or Passport) and returns the status or redirect URI.
|
||||
*/
|
||||
export const postKycSession = <ThrowOnError extends boolean = false>(options: Options<PostKycSessionData, ThrowOnError>) => (options.client ?? client).post<PostKycSessionResponses, PostKycSessionErrors, ThrowOnError>({
|
||||
url: '/kyc/session',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get My User Information
|
||||
*
|
||||
* Fetches the complete profile data for the user associated with the provided session/token.
|
||||
*/
|
||||
export const getUserInfo = <ThrowOnError extends boolean = false>(options?: Options<GetUserInfoData, ThrowOnError>) => (options?.client ?? client).get<GetUserInfoResponses, GetUserInfoErrors, ThrowOnError>({ url: '/user/info', ...options });
|
||||
|
||||
/**
|
||||
* Get Other User Information
|
||||
*
|
||||
* Fetches the complete profile data for the user associated with the provided session/token.
|
||||
*/
|
||||
export const getUserInfoByUserId = <ThrowOnError extends boolean = false>(options: Options<GetUserInfoByUserIdData, ThrowOnError>) => (options.client ?? client).get<GetUserInfoByUserIdResponses, GetUserInfoByUserIdErrors, ThrowOnError>({ url: '/user/info/{user_id}', ...options });
|
||||
|
||||
/**
|
||||
* List Users
|
||||
*
|
||||
* Fetches a list of users with support for pagination via limit and offset. Data is sourced from the search engine for high performance.
|
||||
*/
|
||||
export const getUserList = <ThrowOnError extends boolean = false>(options: Options<GetUserListData, ThrowOnError>) => (options.client ?? client).get<GetUserListResponses, GetUserListErrors, ThrowOnError>({ url: '/user/list', ...options });
|
||||
|
||||
/**
|
||||
* Update User Information
|
||||
*
|
||||
* Updates specific profile fields such as username, nickname, subtitle, avatar (URL), and bio (Base64).
|
||||
* Validation: Username (5-255 chars), Nickname (max 24 chars), Subtitle (max 32 chars).
|
||||
*/
|
||||
export const patchUserUpdate = <ThrowOnError extends boolean = false>(options: Options<PatchUserUpdateData, ThrowOnError>) => (options.client ?? client).patch<PatchUserUpdateResponses, PatchUserUpdateErrors, ThrowOnError>({
|
||||
url: '/user/update',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
1178
src/client/types.gen.ts
Normal file
1178
src/client/types.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
417
src/client/zod.gen.ts
Normal file
417
src/client/zod.gen.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const zDataEventIndexDoc = z.object({
|
||||
checkin_count: z.number().int().optional(),
|
||||
description: z.string().optional(),
|
||||
enable_kyc: z.boolean().optional(),
|
||||
end_time: z.string().optional(),
|
||||
event_id: z.string().optional(),
|
||||
is_checked_in: z.boolean().optional(),
|
||||
is_joined: z.boolean().optional(),
|
||||
join_count: z.number().int().optional(),
|
||||
name: z.string().optional(),
|
||||
start_time: z.string().optional(),
|
||||
thumbnail: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
});
|
||||
|
||||
export const zDataUserIndexDoc = z.object({
|
||||
avatar: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
username: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAgendaSubmitData = z.object({
|
||||
description: z.string().optional(),
|
||||
event_id: z.string().optional(),
|
||||
name: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAgendaSubmitResponse = z.object({
|
||||
agenda_id: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthExchangeData = z.object({
|
||||
client_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
state: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthExchangeResponse = z.object({
|
||||
redirect_uri: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthMagicData = z.object({
|
||||
client_id: z.string().optional(),
|
||||
client_ip: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
turnstile_token: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthMagicResponse = z.object({
|
||||
uri: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthRefreshData = z.object({
|
||||
refresh_token: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthTokenData = z.object({
|
||||
code: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceAuthTokenResponse = z.object({
|
||||
access_token: z.string().optional(),
|
||||
refresh_token: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventCheckinQueryResponse = z.object({
|
||||
checkin_at: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventCheckinResponse = z.object({
|
||||
checkin_code: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventCheckinSubmitData = z.object({
|
||||
checkin_code: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventEventJoinData = z.object({
|
||||
event_id: z.string().optional(),
|
||||
kyc_id: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventEventJoinResponse = z.object({
|
||||
attendance_id: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceKycKycQueryData = z.object({
|
||||
kyc_id: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceKycKycQueryResponse = z.object({
|
||||
status: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceKycKycSessionData = z.object({
|
||||
identity: z.string().optional(),
|
||||
type: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceKycKycSessionResponse = z.object({
|
||||
kyc_id: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
status: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceUserUserInfoData = z.object({
|
||||
allow_public: z.boolean().optional(),
|
||||
avatar: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
permission_level: z.number().int().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
username: z.string().optional()
|
||||
});
|
||||
|
||||
export const zServiceEventAttendanceListResponse = z.object({
|
||||
attendance_id: z.string().optional(),
|
||||
kyc_info: z.unknown().optional(),
|
||||
kyc_type: z.string().optional(),
|
||||
user_info: zServiceUserUserInfoData.optional()
|
||||
});
|
||||
|
||||
export const zUtilsRespStatus = z.object({
|
||||
code: z.number().int().optional(),
|
||||
data: z.unknown().optional(),
|
||||
error_id: z.string().optional(),
|
||||
status: z.string().optional()
|
||||
});
|
||||
|
||||
export const zPostAgendaSubmitData = z.object({
|
||||
body: zServiceAgendaSubmitData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
export const zPostAgendaSubmitResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceAgendaSubmitResponse.optional()
|
||||
}));
|
||||
|
||||
export const zPostAuthExchangeData = z.object({
|
||||
body: zServiceAuthExchangeData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful exchange
|
||||
*/
|
||||
export const zPostAuthExchangeResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceAuthExchangeResponse.optional()
|
||||
}));
|
||||
|
||||
export const zPostAuthMagicData = z.object({
|
||||
body: zServiceAuthMagicData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful request
|
||||
*/
|
||||
export const zPostAuthMagicResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceAuthMagicResponse.optional()
|
||||
}));
|
||||
|
||||
export const zGetAuthRedirectData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string(),
|
||||
code: z.string(),
|
||||
state: z.string().optional()
|
||||
})
|
||||
});
|
||||
|
||||
export const zPostAuthRefreshData = z.object({
|
||||
body: zServiceAuthRefreshData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful rotation
|
||||
*/
|
||||
export const zPostAuthRefreshResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceAuthTokenResponse.optional()
|
||||
}));
|
||||
|
||||
export const zPostAuthTokenData = z.object({
|
||||
body: zServiceAuthTokenData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful token issuance
|
||||
*/
|
||||
export const zPostAuthTokenResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceAuthTokenResponse.optional()
|
||||
}));
|
||||
|
||||
export const zGetEventAttendanceData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
event_id: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful retrieval
|
||||
*/
|
||||
export const zGetEventAttendanceResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.array(zServiceEventAttendanceListResponse).optional()
|
||||
}));
|
||||
|
||||
export const zGetEventCheckinData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
event_id: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Successfully generated code
|
||||
*/
|
||||
export const zGetEventCheckinResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceEventCheckinResponse.optional()
|
||||
}));
|
||||
|
||||
export const zGetEventCheckinQueryData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
event_id: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Current attendance status
|
||||
*/
|
||||
export const zGetEventCheckinQueryResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceEventCheckinQueryResponse.optional()
|
||||
}));
|
||||
|
||||
export const zPostEventCheckinSubmitData = z.object({
|
||||
body: zServiceEventCheckinSubmitData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Attendance marked successfully
|
||||
*/
|
||||
export const zPostEventCheckinSubmitResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.record(z.unknown()).optional()
|
||||
}));
|
||||
|
||||
export const zGetEventInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
event_id: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful retrieval
|
||||
*/
|
||||
export const zGetEventInfoResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zDataEventIndexDoc.optional()
|
||||
}));
|
||||
|
||||
export const zPostEventJoinData = z.object({
|
||||
body: zServiceEventEventJoinData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successfully joined the event
|
||||
*/
|
||||
export const zPostEventJoinResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceEventEventJoinResponse.optional()
|
||||
}));
|
||||
|
||||
export const zGetEventJoinedData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
limit: z.number().int().optional(),
|
||||
offset: z.number().int().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful retrieval of joined events
|
||||
*/
|
||||
export const zGetEventJoinedResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.array(zDataEventIndexDoc).optional()
|
||||
}));
|
||||
|
||||
export const zGetEventListData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
limit: z.number().int().optional(),
|
||||
offset: z.number().int().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful paginated list retrieval
|
||||
*/
|
||||
export const zGetEventListResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.array(zDataEventIndexDoc).optional()
|
||||
}));
|
||||
|
||||
export const zPostKycQueryData = z.object({
|
||||
body: zServiceKycKycQueryData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Query processed (success/pending/failed)
|
||||
*/
|
||||
export const zPostKycQueryResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceKycKycQueryResponse.optional()
|
||||
}));
|
||||
|
||||
export const zPostKycSessionData = z.object({
|
||||
body: zServiceKycKycSessionData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Session created successfully
|
||||
*/
|
||||
export const zPostKycSessionResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceKycKycSessionResponse.optional()
|
||||
}));
|
||||
|
||||
export const zGetUserInfoData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful profile retrieval
|
||||
*/
|
||||
export const zGetUserInfoResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceUserUserInfoData.optional()
|
||||
}));
|
||||
|
||||
export const zGetUserInfoByUserIdData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.object({
|
||||
user_id: z.string()
|
||||
}),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful profile retrieval
|
||||
*/
|
||||
export const zGetUserInfoByUserIdResponse = zUtilsRespStatus.and(z.object({
|
||||
data: zServiceUserUserInfoData.optional()
|
||||
}));
|
||||
|
||||
export const zGetUserListData = z.object({
|
||||
body: z.never().optional(),
|
||||
path: z.never().optional(),
|
||||
query: z.object({
|
||||
limit: z.string().optional(),
|
||||
offset: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful paginated list retrieval
|
||||
*/
|
||||
export const zGetUserListResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.array(zDataUserIndexDoc).optional()
|
||||
}));
|
||||
|
||||
export const zPatchUserUpdateData = z.object({
|
||||
body: zServiceUserUserInfoData,
|
||||
path: z.never().optional(),
|
||||
query: z.never().optional()
|
||||
});
|
||||
|
||||
/**
|
||||
* Successful profile update
|
||||
*/
|
||||
export const zPatchUserUpdateResponse = zUtilsRespStatus.and(z.object({
|
||||
data: z.record(z.unknown()).optional()
|
||||
}));
|
||||
28
src/components/checkin/checkin-qr.dialog.container.tsx
Normal file
28
src/components/checkin/checkin-qr.dialog.container.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EventInfo } from '../events/types';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { useState } from 'react';
|
||||
import { useCheckinCode } from '@/hooks/data/useCheckinCode';
|
||||
import { Dialog } from '../ui/dialog';
|
||||
import { CheckinQrDialogError } from './checkin-qr.dialog.error';
|
||||
import { CheckinQrDialogSkeleton } from './checkin-qr.dialog.skeleton';
|
||||
import { CheckinQrDialogView } from './checkin-qr.dialog.view';
|
||||
|
||||
export function CheckinQrDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { data, isLoading, isError } = useCheckinCode(event.eventId, isDialogOpen);
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
{children}
|
||||
{isLoading && (
|
||||
<CheckinQrDialogSkeleton />
|
||||
)}
|
||||
{isError && (
|
||||
<CheckinQrDialogError />
|
||||
)}
|
||||
{!isLoading && !isError && !isNil(data) && (
|
||||
<CheckinQrDialogView checkinCode={String(data.data!.checkin_code)} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
18
src/components/checkin/checkin-qr.dialog.error.tsx
Normal file
18
src/components/checkin/checkin-qr.dialog.error.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TicketX } from 'lucide-react';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function CheckinQrDialogError() {
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>签到码</DialogTitle>
|
||||
<DialogDescription>
|
||||
签到码获取出错。请稍后重试。
|
||||
<div className="flex justify-center my-12">
|
||||
<TicketX size={100} className="stroke-[1.5]" />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
23
src/components/checkin/checkin-qr.dialog.skeleton.tsx
Normal file
23
src/components/checkin/checkin-qr.dialog.skeleton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { QRCode } from '../ui/shadcn-io/qr-code';
|
||||
|
||||
export function CheckinQrDialogSkeleton() {
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>签到码</DialogTitle>
|
||||
<DialogDescription>
|
||||
请工作人员扫描下面的二维码为你签到。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data="welcome to join the conference" className="size-60 blur-sm" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 text-2xl text-primary/80 justify-center">
|
||||
加载中...
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
23
src/components/checkin/checkin-qr.dialog.view.tsx
Normal file
23
src/components/checkin/checkin-qr.dialog.view.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { QRCode } from '../ui/shadcn-io/qr-code';
|
||||
|
||||
export function CheckinQrDialogView({ checkinCode }: { checkinCode: string }) {
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>签到码</DialogTitle>
|
||||
<DialogDescription>
|
||||
请工作人员扫描下面的二维码为你签到。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||
<QRCode data={checkinCode} className="size-60" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex flex-1 items-center ml-2 font-mono text-2xl tracking-widest text-primary/80 justify-center">
|
||||
{checkinCode}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
19
src/components/checkin/checkin-scanner-nav.container.tsx
Normal file
19
src/components/checkin/checkin-scanner-nav.container.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useCheckinSubmit } from '@/hooks/data/useCheckinSubmit';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { CheckinScannerNavView } from './checkin-scanner-nav.view';
|
||||
|
||||
export function CheckinScannerNavContainer() {
|
||||
const { data } = useUserInfo();
|
||||
const { mutate, isPending } = useCheckinSubmit();
|
||||
|
||||
if ((data.data?.permission_level ?? 0) <= 20) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckinScannerNavView
|
||||
onScan={code => mutate({ body: { checkin_code: code } })}
|
||||
isPending={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/checkin/checkin-scanner-nav.view.tsx
Normal file
40
src/components/checkin/checkin-scanner-nav.view.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { IconScan } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||
import { CheckinScannerDialogView } from './checkin-scanner.dialog.view';
|
||||
|
||||
interface CheckinScannerNavViewProps {
|
||||
onScan: (code: string) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function CheckinScannerNavView({ onScan, isPending }: CheckinScannerNavViewProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleScan = (value: string) => {
|
||||
if (isPending)
|
||||
return;
|
||||
|
||||
if (!/^\d{6}$/.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onScan(value);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<SidebarMenuItem>
|
||||
<DialogTrigger asChild>
|
||||
<SidebarMenuButton tooltip="扫码签到">
|
||||
<IconScan />
|
||||
<span>扫码签到</span>
|
||||
</SidebarMenuButton>
|
||||
</DialogTrigger>
|
||||
</SidebarMenuItem>
|
||||
<CheckinScannerDialogView onScan={handleScan} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
50
src/components/checkin/checkin-scanner.dialog.view.tsx
Normal file
50
src/components/checkin/checkin-scanner.dialog.view.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Scanner } from '@yudiel/react-qr-scanner';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '../ui/input-otp';
|
||||
|
||||
export function CheckinScannerDialogView({ onScan }: { onScan: (value: string) => void }) {
|
||||
return (
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>扫描签到码</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-6 items-center">
|
||||
<Scanner
|
||||
onScan={(result) => {
|
||||
if (result.length > 0) {
|
||||
onScan(result[0].rawValue);
|
||||
}
|
||||
}}
|
||||
onError={(error) => { throw error; }}
|
||||
/>
|
||||
|
||||
<div className="relative w-full flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
手动输入
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
onComplete={(value: string) => onScan(value)}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
30
src/components/events/AGENTS.md
Normal file
30
src/components/events/AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# EVENTS & KYC SYSTEM
|
||||
|
||||
## OVERVIEW
|
||||
This module uses a Container-View architecture to manage event participation. `EventGridContainer` acts as the primary orchestrator, handling data fetching and determining the appropriate interaction flow based on event requirements. This separation ensures that the presentation layer remains decoupled from the complex business logic of KYC and event joining.
|
||||
|
||||
## STRUCTURE
|
||||
- `event-grid/`: Core listing components (Container/View/Skeleton).
|
||||
- `kyc/`: Isolated KYC state machine and multi-stage dialog views.
|
||||
- `event-join.dialog.container.tsx`: Standard participation flow for non-KYC events.
|
||||
- `nickname-needed.dialog.container.tsx`: Interstitial for profile completion.
|
||||
- `joined-events.containers.tsx`: Specialized views for events the user has already entered.
|
||||
- `types.ts`: Shared domain types and mappers for event data.
|
||||
|
||||
## KEY CONCEPTS
|
||||
### Branching Logic
|
||||
`EventGridContainer` dynamically selects the interaction container based on `eventInfo.requireKyc`. This ensures the UI remains responsive to backend configuration without bloating the view components.
|
||||
- `requireKyc: true` -> `KycDialogContainer`
|
||||
- `requireKyc: false` -> `EventJoinDialogContainer`
|
||||
|
||||
### KYC State Machine
|
||||
The KYC flow is managed via a per-instance Zustand store (`createKycStore`). It handles transitions between stages: `prompt`, `methodSelection`, `pending`, `success`, and `failed`. This isolation prevents KYC state from leaking into the global application state and allows for independent testing of the flow.
|
||||
|
||||
### Dynamic Dialogs
|
||||
Dialogs are triggered via `DialogTrigger`, but their internal content is controlled by the container's state. This allows for complex, multi-step interactions within a single modal context, reducing the need for multiple separate dialog components.
|
||||
|
||||
## GOTCHAS
|
||||
- **Cache Invalidation**: The `useEvents` query must be invalidated after successful KYC or event joining to update the "Joined" status in the grid.
|
||||
- **Store Lifecycle**: The KYC store is created per event instance. It must reset correctly when the dialog closes to avoid stale state on subsequent opens.
|
||||
- **Zustand Devtools**: Use the devtools middleware to debug state transitions during the KYC flow.
|
||||
- **Type Safety**: Ensure `toEventInfo` mapper in `types.ts` is updated when the OpenAPI schema changes to prevent runtime errors in the grid.
|
||||
40
src/components/events/event-card.skeleton.tsx
Normal file
40
src/components/events/event-card.skeleton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Calendar } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function EventCardSkeleton() {
|
||||
return (
|
||||
<Card className="relative mx-auto w-full max-w-sm pt-0">
|
||||
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
|
||||
<Skeleton
|
||||
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardAction>
|
||||
<Badge variant="secondary" className="bg-accent animate-pulse text-accent select-none">Official</Badge>
|
||||
</CardAction>
|
||||
<CardTitle>
|
||||
<Skeleton className="h-4 max-w-48" />
|
||||
</CardTitle>
|
||||
<CardDescription className="flex flex-row items-center text-xs">
|
||||
<Calendar className="size-4 mr-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1">
|
||||
<Skeleton className="h-5 max-w-64" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Skeleton className="h-9 px-4 py-2 w-full"></Skeleton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
48
src/components/events/event-card.view.tsx
Normal file
48
src/components/events/event-card.view.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { EventInfo } from './types';
|
||||
import dayjs from 'dayjs';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function EventCardView({ eventInfo, actionFooter }: { eventInfo: EventInfo; actionFooter: React.ReactNode }) {
|
||||
const { type, coverImage, eventName, description, startTime, endTime } = eventInfo;
|
||||
const startDayJs = dayjs(startTime);
|
||||
const endDayJs = dayjs(endTime);
|
||||
return (
|
||||
<Card className="relative mx-auto w-full max-w-sm pt-0">
|
||||
<div className="absolute inset-0 z-30 aspect-video bg-black/10" />
|
||||
<img
|
||||
src={coverImage}
|
||||
alt="Event cover"
|
||||
className="relative z-20 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<Skeleton
|
||||
className="absolute z-15 aspect-video w-full object-cover rounded-t-xl"
|
||||
/>
|
||||
<CardHeader>
|
||||
<CardAction>
|
||||
{type === 'official' ? <Badge variant="secondary">Official</Badge> : <Badge variant="destructive">Party</Badge>}
|
||||
</CardAction>
|
||||
<CardTitle>{eventName}</CardTitle>
|
||||
<CardDescription className="flex flex-row items-center text-xs">
|
||||
<Calendar className="size-4 mr-2" />
|
||||
{`${startDayJs.format('YYYY/MM/DD')} - ${endDayJs.format('YYYY/MM/DD')}`}
|
||||
</CardDescription>
|
||||
<CardDescription className="mt-1">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{actionFooter}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
46
src/components/events/event-grid/event-grid.container.tsx
Normal file
46
src/components/events/event-grid/event-grid.container.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEvents } from '@/hooks/data/useEvents';
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogTrigger } from '../../ui/dialog';
|
||||
import { EventJoinDialogContainer } from '../event-join.dialog.container';
|
||||
import { KycDialogContainer } from '../kyc/kyc.dialog.container';
|
||||
import { toEventInfo } from '../types';
|
||||
import { EventGridSkeleton } from './event-grid.skeleton';
|
||||
import { EventGridView } from './event-grid.view';
|
||||
|
||||
export function EventGridContainer() {
|
||||
const { data, isLoading } = useEvents();
|
||||
|
||||
const events = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.data ?? []).map(toEventInfo) ?? [];
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
isLoading
|
||||
? <EventGridSkeleton />
|
||||
: (
|
||||
<EventGridView
|
||||
events={events}
|
||||
footer={(eventInfo) => {
|
||||
const Container = eventInfo.requireKyc ? KycDialogContainer : EventJoinDialogContainer;
|
||||
|
||||
return (
|
||||
<Container event={eventInfo}>
|
||||
{eventInfo.isJoined
|
||||
? (
|
||||
<Button className="w-full" disabled>
|
||||
已加入
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full">加入活动</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
16
src/components/events/event-grid/event-grid.empty.tsx
Normal file
16
src/components/events/event-grid/event-grid.empty.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FileQuestionMark } from 'lucide-react';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty';
|
||||
|
||||
export function EventGridEmpty() {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileQuestionMark />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>暂无活动</EmptyTitle>
|
||||
<EmptyDescription>前面的区域 以后再来探索吧</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
16
src/components/events/event-grid/event-grid.error.tsx
Normal file
16
src/components/events/event-grid/event-grid.error.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { FileExclamationPoint } from 'lucide-react';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../../ui/empty';
|
||||
|
||||
export function EventGridError() {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FileExclamationPoint />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>活动列表加载失败</EmptyTitle>
|
||||
<EmptyDescription>前面的区域 以后再来探索吧</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
12
src/components/events/event-grid/event-grid.skeleton.tsx
Normal file
12
src/components/events/event-grid/event-grid.skeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EventCardSkeleton } from '../event-card.skeleton';
|
||||
|
||||
export function EventGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<EventCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/components/events/event-grid/event-grid.view.tsx
Normal file
18
src/components/events/event-grid/event-grid.view.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { EventInfo } from '../types';
|
||||
import { EventCardView } from '../event-card.view';
|
||||
import { EventGridEmpty } from './event-grid.empty';
|
||||
|
||||
export function EventGridView({ events, footer }: { events: EventInfo[]; footer: (event: EventInfo) => React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{events.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{events.map(event => (
|
||||
<EventCardView key={event.eventId} eventInfo={event} actionFooter={footer(event)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{events.length === 0 && <EventGridEmpty />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/components/events/event-join.dialog.container.tsx
Normal file
25
src/components/events/event-join.dialog.container.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useJoinEvent } from '@/hooks/data/useJoinEvent';
|
||||
import { Dialog } from '../ui/dialog';
|
||||
import { EventJoinDialogView } from './event-join.dialog.view';
|
||||
|
||||
export function EventJoinDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
|
||||
const { mutateAsync, isPending } = useJoinEvent();
|
||||
const join = useCallback(() => {
|
||||
mutateAsync({ body: { event_id: event.eventId } }).then(() => {
|
||||
toast('加入活动成功');
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('加入活动失败');
|
||||
});
|
||||
}, [event.eventId, mutateAsync]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
{children}
|
||||
<EventJoinDialogView event={event} onJoinEvent={join} isPending={isPending} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
26
src/components/events/event-join.dialog.view.tsx
Normal file
26
src/components/events/event-join.dialog.view.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
|
||||
export function EventJoinDialogView({ event, onJoinEvent, isPending }: { event: EventInfo; onJoinEvent: () => void; isPending: boolean }) {
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>加入活动</DialogTitle>
|
||||
<DialogDescription>
|
||||
是否确认要加入活动
|
||||
{' '}
|
||||
{event.eventName}
|
||||
?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={onJoinEvent} disabled={isPending}>{isPending ? <Spinner /> : '加入'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
48
src/components/events/joined-events.containers.tsx
Normal file
48
src/components/events/joined-events.containers.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { EventInfo } from './types';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useJoinedEvents } from '@/hooks/data/useJoinedEvents';
|
||||
import { isInDateRange } from '@/lib/utils';
|
||||
import { CheckinQrDialogContainer } from '../checkin/checkin-qr.dialog.container';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogTrigger } from '../ui/dialog';
|
||||
import { EventGridSkeleton } from './event-grid/event-grid.skeleton';
|
||||
import { EventGridView } from './event-grid/event-grid.view';
|
||||
import { toEventInfo } from './types';
|
||||
|
||||
export function JoinedEventGridFooter({ event }: { event: EventInfo }) {
|
||||
const isOutOfDateRange = !isInDateRange(event.startTime, event.endTime);
|
||||
const isCheckedIn = event.isCheckedIn;
|
||||
const canCheckIn = !isOutOfDateRange && !isCheckedIn;
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="flex flex-row justify-between w-full gap-4">
|
||||
<CheckinQrDialogContainer event={event}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex-1" disabled={!canCheckIn}>
|
||||
{isOutOfDateRange && '未到签到时间'}
|
||||
{isCheckedIn && '已签到'}
|
||||
{canCheckIn && '签到'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</CheckinQrDialogContainer>
|
||||
<Button className="flex-1" onClick={() => void navigate({ to: `/events/$eventId`, params: { eventId: event.eventId } })}>查看详情</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function JoinedEventsContainer() {
|
||||
const { data, isLoading } = useJoinedEvents();
|
||||
|
||||
return (
|
||||
isLoading
|
||||
? <EventGridSkeleton />
|
||||
: (
|
||||
<EventGridView
|
||||
events={data.pages.flatMap(page => page.data ?? []).map(toEventInfo)}
|
||||
footer={event => (
|
||||
<JoinedEventGridFooter event={event} />
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-failed.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-failed.dialog.view.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycFailedDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>失败</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>提交身份认证失败,请重试。</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<X size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
178
src/components/events/kyc/kyc-method-selection.dialog.view.tsx
Normal file
178
src/components/events/kyc/kyc-method-selection.dialog.view.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { KycSubmission } from './kyc.types';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
const CnridSchema = z.object({
|
||||
cnrid: z.string().min(18, '身份证号应为18位').max(18, '身份证号应为18位'),
|
||||
name: z.string().min(2, '姓名应至少2个字符').max(10, '姓名应不超过10个字符'),
|
||||
});
|
||||
|
||||
function CnridForm({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
cnrid: '',
|
||||
name: '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: CnridSchema,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await onSubmit({
|
||||
method: 'cnrid',
|
||||
...values.value,
|
||||
}).catch(() => {
|
||||
toast('认证失败,请稍后再试');
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<form.Field name="name">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">姓名</FieldLabel>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="cnrid">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cnrid">身份证号</FieldLabel>
|
||||
<Input
|
||||
id="cnrid"
|
||||
name="cnrid"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<DialogFooter>
|
||||
<form.Subscribe
|
||||
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
|
||||
children={([canSubmit, isPristine, isSubmitting]) => (
|
||||
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? <Spinner /> : '开始认证'}</Button>
|
||||
)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const PassportSchema = z.object({
|
||||
passportId: z.string().min(9, '护照号应为9个字符').max(9, '护照号应为9个字符'),
|
||||
});
|
||||
|
||||
function PassportForm({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
passportId: '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: PassportSchema,
|
||||
},
|
||||
onSubmit: async (values) => {
|
||||
await onSubmit({
|
||||
method: 'passport',
|
||||
...values.value,
|
||||
}).catch(() => {
|
||||
toast('认证失败,请稍后再试');
|
||||
});
|
||||
},
|
||||
});
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<form.Field name="passportId">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="passportId">护照号</FieldLabel>
|
||||
<Input
|
||||
id="passportId"
|
||||
name="passportId"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<DialogFooter>
|
||||
<form.Subscribe
|
||||
selector={state => [state.canSubmit, state.isPristine, state.isSubmitting]}
|
||||
children={([canSubmit, isPristine, isSubmitting]) => (
|
||||
<Button type="submit" disabled={!canSubmit || isPristine || isSubmitting}>{isSubmitting ? '...' : '开始认证'}</Button>
|
||||
)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type OnSubmit = (submission: KycSubmission) => Promise<void>;
|
||||
|
||||
export function KycMethodSelectionDialogView({ onSubmit }: { onSubmit: OnSubmit }) {
|
||||
const [kycMethod, setKycMethod] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择身份认证模式</DialogTitle>
|
||||
<DialogDescription className="prose">
|
||||
<p>我们支持身份证和护照认证。</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Label htmlFor="selection">身份认证模式</Label>
|
||||
<Select onValueChange={setKycMethod}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="请选择..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup id="selection">
|
||||
<SelectItem value="cnrid">身份证</SelectItem>
|
||||
<SelectItem value="passport">护照</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{kycMethod === 'cnrid' && <CnridForm onSubmit={onSubmit} />}
|
||||
{kycMethod === 'passport' && <PassportForm onSubmit={onSubmit} />}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-pending.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-pending.dialog.view.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { HashLoader } from 'react-spinners/esm';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycPendingDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>等待身份认证结果</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>认证页面已打开。正在等待认证服务器回传数据...</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<HashLoader color="#e0e0e0" size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
24
src/components/events/kyc/kyc-prompt.dialog.view.tsx
Normal file
24
src/components/events/kyc/kyc-prompt.dialog.view.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Button } from '../../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycPromptDialogView({ next }: { next: () => void }) {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>需要身份认证</DialogTitle>
|
||||
<DialogDescription className="prose">
|
||||
<p>为了确保会议的安全性及合规性,我们需要对参会者进行实名认证。</p>
|
||||
<p>您的个人隐私对我们至关重要:</p>
|
||||
<ul>
|
||||
<li>数据加密:您的所有个人敏感信息均会通过 AES-256 标准进行强加密存储。</li>
|
||||
<li>用途限制:收集的信息仅用于本次活动的身份核实,不会用于任何商业用途。</li>
|
||||
<li>按时销毁:所有身份证明文件及关联的敏感原始数据将在活动完成后最多 30 天内从服务器中彻底删除。</li>
|
||||
</ul>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={next}>下一步</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
18
src/components/events/kyc/kyc-success.dialog.view.tsx
Normal file
18
src/components/events/kyc/kyc-success.dialog.view.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
|
||||
export function KycSuccessDialogView() {
|
||||
return (
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>成功</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>已完成身份认证。</p>
|
||||
<div className="flex justify-center my-12">
|
||||
<Check size={100} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
119
src/components/events/kyc/kyc.dialog.container.tsx
Normal file
119
src/components/events/kyc/kyc.dialog.container.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { EventInfo } from '../types';
|
||||
import type { KycSubmission } from './kyc.types';
|
||||
import { Dialog } from '@radix-ui/react-dialog';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useStore } from 'zustand';
|
||||
import { postKycQuery } from '@/client';
|
||||
import { useCreateKycSession } from '@/hooks/data/useCreateKycSession';
|
||||
import { useJoinEvent } from '@/hooks/data/useJoinEvent';
|
||||
import { KycFailedDialogView } from './kyc-failed.dialog.view';
|
||||
import { KycMethodSelectionDialogView } from './kyc-method-selection.dialog.view';
|
||||
import { KycPendingDialogView } from './kyc-pending.dialog.view';
|
||||
import { KycPromptDialogView } from './kyc-prompt.dialog.view';
|
||||
import { KycSuccessDialogView } from './kyc-success.dialog.view';
|
||||
import { createKycStore } from './kyc.state';
|
||||
|
||||
export function KycDialogContainer({ event, children }: { event: EventInfo; children: React.ReactNode }) {
|
||||
const [store] = useState(() => createKycStore(event.eventId));
|
||||
const isDialogOpen = useStore(store, s => s.isDialogOpen);
|
||||
const setIsDialogOpen = useStore(store, s => s.setIsDialogOpen);
|
||||
const stage = useStore(store, s => s.stage);
|
||||
const setStage = useStore(store, s => s.setStage);
|
||||
const setKycId = useStore(store, s => s.setKycId);
|
||||
|
||||
const { mutateAsync: createKycSessionAsync } = useCreateKycSession();
|
||||
const { mutateAsync: joinEventAsync } = useJoinEvent();
|
||||
|
||||
const joinEvent = useCallback(async (eventId: string, kycId: string, abortSignal?: AbortSignal) => {
|
||||
try {
|
||||
await joinEventAsync({
|
||||
signal: abortSignal,
|
||||
body: { event_id: eventId, kyc_id: kycId },
|
||||
});
|
||||
setStage('success');
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Error joining event:', e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [joinEventAsync, setStage]);
|
||||
|
||||
const onKycSessionCreate = useCallback(async (submission: KycSubmission) => {
|
||||
try {
|
||||
const { data } = await createKycSessionAsync(submission);
|
||||
setKycId(data!.kyc_id!);
|
||||
if (data!.status === 'success') {
|
||||
await joinEvent(event.eventId, data!.kyc_id!, undefined);
|
||||
}
|
||||
else if (data!.status === 'processing') {
|
||||
window.open(data!.redirect_uri, '_blank');
|
||||
setStage('pending');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
setStage('failed');
|
||||
}
|
||||
}, [event.eventId, joinEvent, createKycSessionAsync, setKycId, setStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stage !== 'pending' || !isDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await postKycQuery({
|
||||
signal: controller.signal,
|
||||
body: { kyc_id: store.getState().kycId! },
|
||||
});
|
||||
|
||||
const status = data?.data?.status;
|
||||
|
||||
if (status === 'success') {
|
||||
void joinEvent(event.eventId, store.getState().kycId!, controller.signal);
|
||||
}
|
||||
else if (status === 'failed') {
|
||||
setStage('failed');
|
||||
}
|
||||
else if (status === 'pending') {
|
||||
timer = setTimeout(() => void poll(), 1000);
|
||||
}
|
||||
else {
|
||||
// What the fuck?
|
||||
setStage('failed');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
if ((e as Error).name === 'AbortError')
|
||||
return;
|
||||
console.error('Error fetching KYC status:', e);
|
||||
setStage('failed');
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [stage, store, setStage, isDialogOpen, joinEvent, event.eventId]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
>
|
||||
{children}
|
||||
{stage === 'prompt' && <KycPromptDialogView next={() => setStage('methodSelection')} />}
|
||||
{stage === 'methodSelection' && <KycMethodSelectionDialogView onSubmit={onKycSessionCreate} />}
|
||||
{stage === 'pending' && <KycPendingDialogView />}
|
||||
{stage === 'success' && <KycSuccessDialogView />}
|
||||
{stage === 'failed' && <KycFailedDialogView />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
34
src/components/events/kyc/kyc.state.ts
Normal file
34
src/components/events/kyc/kyc.state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createStore } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
interface KycState {
|
||||
isDialogOpen: boolean;
|
||||
eventIdToJoin: string;
|
||||
kycId: string | null;
|
||||
stage: 'prompt' | 'methodSelection' | 'pending' | 'success' | 'failed';
|
||||
|
||||
setIsDialogOpen: (open: boolean) => void;
|
||||
setStage: (stage: KycState['stage']) => void;
|
||||
setKycId: (kycId: string) => void;
|
||||
}
|
||||
|
||||
export function createKycStore(eventIdToJoin: string) {
|
||||
const initialState = {
|
||||
isDialogOpen: false,
|
||||
eventIdToJoin,
|
||||
kycId: null,
|
||||
stage: 'prompt' as const,
|
||||
};
|
||||
return createStore<KycState>()(devtools(set => ({
|
||||
...initialState,
|
||||
setIsDialogOpen: (open: boolean) => set(() =>
|
||||
open
|
||||
? { ...initialState, isDialogOpen: true }
|
||||
: { ...initialState, isDialogOpen: false },
|
||||
),
|
||||
setStage: (stage: KycState['stage']) => set(() => ({ stage })),
|
||||
setKycId: (kycId: string) => set(() => ({ kycId })),
|
||||
})));
|
||||
}
|
||||
|
||||
export type KycStore = ReturnType<typeof createKycStore>;
|
||||
8
src/components/events/kyc/kyc.types.ts
Normal file
8
src/components/events/kyc/kyc.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type KycSubmission = {
|
||||
method: 'cnrid';
|
||||
cnrid: string;
|
||||
name: string;
|
||||
} | {
|
||||
method: 'passport';
|
||||
passportId: string;
|
||||
};
|
||||
15
src/components/events/nickname-needed.dialog.container.tsx
Normal file
15
src/components/events/nickname-needed.dialog.container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { Dialog } from '../ui/dialog';
|
||||
import { NicknameNeededDialogView } from './nickname-needed.dialog.view';
|
||||
|
||||
export function NicknameNeededDialogContainer() {
|
||||
const { data } = useUserInfo();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Dialog open={isEmpty(data?.data?.nickname)}>
|
||||
<NicknameNeededDialogView onAction={() => void navigate({ to: '/profile' })} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
24
src/components/events/nickname-needed.dialog.view.tsx
Normal file
24
src/components/events/nickname-needed.dialog.view.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { HatGlasses } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
|
||||
export function NicknameNeededDialogView({ onAction }: { onAction: () => void }) {
|
||||
return (
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>请先填写昵称</DialogTitle>
|
||||
<DialogDescription>
|
||||
如要加入活动,请先于「个人资料」页面填写昵称。
|
||||
<div className="flex justify-center my-12">
|
||||
<HatGlasses size={100} className="stroke-[1.5]" />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onAction}>去填写</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
30
src/components/events/types.ts
Normal file
30
src/components/events/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { DataEventIndexDoc } from '@/client';
|
||||
import PlaceholderImage from '@/assets/event-placeholder.png';
|
||||
|
||||
export interface EventInfo {
|
||||
type: 'official' | 'party';
|
||||
eventId: string;
|
||||
isJoined: boolean;
|
||||
isCheckedIn: boolean;
|
||||
requireKyc: boolean;
|
||||
coverImage: string;
|
||||
eventName: string;
|
||||
description: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
}
|
||||
|
||||
export function toEventInfo(raw: DataEventIndexDoc): EventInfo {
|
||||
return {
|
||||
type: raw.type! as EventInfo['type'],
|
||||
eventId: raw.event_id!,
|
||||
isJoined: raw.is_joined!,
|
||||
requireKyc: raw.enable_kyc!,
|
||||
isCheckedIn: raw.is_checked_in ?? false,
|
||||
coverImage: raw.thumbnail! || PlaceholderImage,
|
||||
eventName: raw.name!,
|
||||
description: raw.description!,
|
||||
startTime: new Date(raw.start_time!),
|
||||
endTime: new Date(raw.end_time!),
|
||||
};
|
||||
}
|
||||
18
src/components/global.error.tsx
Normal file
18
src/components/global.error.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RawError } from '@/lib/types';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { isRawError } from '@/lib/types';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from './ui/empty';
|
||||
|
||||
export function GlobalError({ error }: { error: Error | RawError }) {
|
||||
return (
|
||||
<Empty className="h-screen w-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<TriangleAlert />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>出错了</EmptyTitle>
|
||||
<EmptyDescription>{isRawError(error) ? error.error_id : error.message}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
112
src/components/login-form.tsx
Normal file
112
src/components/login-form.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Spinner } from './ui/spinner';
|
||||
|
||||
export function LoginForm({
|
||||
oauthParams,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
oauthParams: AuthorizeSearchParams;
|
||||
}) {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [token, setToken] = useState<string | null>(import.meta.env.DEV ? 'turnstile_token' : null);
|
||||
const { mutateAsync, isPending } = useGetMagicLink();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
}),
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
await mutateAsync({ body: { email: value.email, turnstile_token: token!, ...oauthParams } });
|
||||
await navigate({ to: '/magicLinkSent', search: { email: value.email } });
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
toast.error('请求登录链接失败');
|
||||
turnstileRef.current?.reset();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = isPending || token === null;
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex size-8 items-center justify-center rounded-md">
|
||||
<NixOSLogo className="size-6" />
|
||||
</div>
|
||||
<span className="sr-only">Nix CN CMS</span>
|
||||
<h1 className="text-xl font-bold">欢迎来到 Nix CN CMS</h1>
|
||||
</div>
|
||||
<form.Field name="email">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">参会登记Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="edolstra@gmail.com"
|
||||
required
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <Spinner />}
|
||||
{token === null ? '等待 Turnstile' : isPending ? '发送中...' : '发送登录链接'}
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey="0x4AAAAAACI5pu-lNWFc6Wu1"
|
||||
options={{
|
||||
refreshExpired: 'auto',
|
||||
}}
|
||||
onSuccess={(token) => {
|
||||
setToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/profile/edit-profile.dialog.container.tsx
Normal file
15
src/components/profile/edit-profile.dialog.container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ServiceUserUserInfoData } from '@/client';
|
||||
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||
import { EditProfileDialogView } from './edit-profile.dialog.view';
|
||||
|
||||
export function EditProfileDialogContainer({ data }: { data: ServiceUserUserInfoData }) {
|
||||
const { mutateAsync } = useUpdateUser();
|
||||
return (
|
||||
<EditProfileDialogView
|
||||
user={data}
|
||||
updateProfile={async (data) => {
|
||||
await mutateAsync({ body: data });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
180
src/components/profile/edit-profile.dialog.view.tsx
Normal file
180
src/components/profile/edit-profile.dialog.view.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { ServiceUserUserInfoData } from '@/client';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import {
|
||||
Input,
|
||||
} from '@/components/ui/input';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string().min(5, '用户名长度至少为5个字符'),
|
||||
nickname: z.string().nonempty('昵称不能为空'),
|
||||
subtitle: z.string(),
|
||||
avatar: z.string().url().or(z.literal('')),
|
||||
allow_public: z.boolean(),
|
||||
});
|
||||
export function EditProfileDialogView({ user, updateProfile }: { user: ServiceUserUserInfoData; updateProfile: (data: ServiceUserUserInfoData) => Promise<void> }) {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
avatar: user.avatar,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
subtitle: user.subtitle,
|
||||
allow_public: user.allow_public,
|
||||
},
|
||||
validators: {
|
||||
onBlur: formSchema,
|
||||
},
|
||||
onSubmit: async ({
|
||||
value,
|
||||
}) => {
|
||||
try {
|
||||
await updateProfile(value);
|
||||
toast.success('个人资料更新成功');
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Form submission error', error);
|
||||
toast.error('更新个人资料失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const id = setTimeout(() => {
|
||||
form.reset();
|
||||
}, 200);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full" size="lg">编辑个人资料</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void form.handleSubmit().then(() => setOpen(false));
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form.Field name="username">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="username">用户名</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder={user.username}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="nickname">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="nickname">昵称</FieldLabel>
|
||||
<Input
|
||||
id="nickname"
|
||||
name="nickname"
|
||||
placeholder={user.nickname}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="subtitle">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="subtitle">副标题</FieldLabel>
|
||||
<Input
|
||||
id="subtitle"
|
||||
name="subtitle"
|
||||
placeholder={user.subtitle}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="avatar">
|
||||
{field => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="avatar">头像链接</FieldLabel>
|
||||
<Input
|
||||
id="avatar"
|
||||
name="avatar"
|
||||
placeholder={user.avatar}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
/>
|
||||
<FieldError errors={field.state.meta.errors} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<form.Field name="allow_public">
|
||||
{field => (
|
||||
<Field orientation="horizontal" className="my-2">
|
||||
<FieldLabel htmlFor="allow_public">公开个人资料</FieldLabel>
|
||||
<Switch id="allow_public" onCheckedChange={e => field.handleChange(e)} defaultChecked={user.allow_public} />
|
||||
</Field>
|
||||
)}
|
||||
</form.Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">取消</Button>
|
||||
</DialogClose>
|
||||
<form.Subscribe
|
||||
selector={state => [state.canSubmit, state.isSubmitting]}
|
||||
children={([canSubmit, isSubmitting]) => (
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{isSubmitting ? <Spinner /> : '保存'}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
17
src/components/profile/profile.container.tsx
Normal file
17
src/components/profile/profile.container.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||
import { useOtherUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { utf8ToBase64 } from '@/lib/utils';
|
||||
import { ProfileView } from './profile.view';
|
||||
|
||||
export function ProfileContainer({ userId }: { userId: string }) {
|
||||
const { data } = useOtherUserInfo(userId);
|
||||
const { mutateAsync } = useUpdateUser();
|
||||
return (
|
||||
<ProfileView
|
||||
user={data.data!}
|
||||
onSaveBio={async (bio) => {
|
||||
await mutateAsync({ body: { bio: utf8ToBase64(bio) } });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/components/profile/profile.error.tsx
Normal file
16
src/components/profile/profile.error.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { UserLock } from 'lucide-react';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
|
||||
|
||||
export function ProfileError({ reason }: { reason: string }) {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<UserLock />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>无法查看此个人资料</EmptyTitle>
|
||||
<EmptyDescription>{reason}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
29
src/components/profile/profile.skeleton.tsx
Normal file
29
src/components/profile/profile.skeleton.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Mail } from 'lucide-react';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function ProfileSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
|
||||
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-row gap-3 w-full lg:flex-col">
|
||||
<Skeleton className="size-16 rounded-full border-2 border-muted lg:size-64" />
|
||||
<div className="flex flex-1 flex-col justify-center lg:mt-3 gap-2">
|
||||
<Skeleton className="w-32 h-8" />
|
||||
<Skeleton className="w-20 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
|
||||
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||
<Skeleton className="w-32 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-64 h-[40px]" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
|
||||
</Skeleton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/profile/profile.view.tsx
Normal file
98
src/components/profile/profile.view.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ServiceUserUserInfoData } from '@/client';
|
||||
import { identicon } from '@dicebear/collection';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import {
|
||||
isEmpty,
|
||||
isNil,
|
||||
} from 'lodash-es';
|
||||
import { Mail, Pencil } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import { toast } from 'sonner';
|
||||
import { Avatar, AvatarImage } from '@/components/ui/avatar';
|
||||
import { base64ToUtf8 } from '@/lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import { EditProfileDialogContainer } from './edit-profile.dialog.container';
|
||||
|
||||
export function ProfileView({ user, onSaveBio }: { user: ServiceUserUserInfoData; onSaveBio: (bio: string) => Promise<void> }) {
|
||||
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio ?? ''));
|
||||
const [enableBioEdit, setEnableBioEdit] = useState(false);
|
||||
const [isSubmittingBio, setIsSubmittingBio] = useState(false);
|
||||
|
||||
const IdentIcon = useMemo(() => {
|
||||
const avatar = createAvatar(identicon, {
|
||||
size: 128,
|
||||
seed: user.user_id,
|
||||
}).toDataUri();
|
||||
return <img src={avatar} alt="Avatar" />;
|
||||
}, [user.user_id]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
|
||||
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
<div className="flex flex-row gap-3 w-full lg:flex-col">
|
||||
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
|
||||
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
|
||||
</Avatar>
|
||||
<div className="flex flex-1 flex-col justify-center lg:mt-3">
|
||||
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
|
||||
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
|
||||
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
<EditProfileDialogContainer data={user} />
|
||||
</div>
|
||||
</div>
|
||||
<section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
|
||||
{/* Bio */}
|
||||
{enableBioEdit
|
||||
? (
|
||||
<MDEditor
|
||||
value={bio}
|
||||
onChange={setBio}
|
||||
height="100%"
|
||||
/>
|
||||
)
|
||||
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
|
||||
<Button
|
||||
className="absolute bottom-4 right-4"
|
||||
// eslint-disable-next-line ts/no-misused-promises
|
||||
onClick={async () => {
|
||||
if (!enableBioEdit) {
|
||||
setEnableBioEdit(true);
|
||||
}
|
||||
else {
|
||||
if (!isNil(bio)) {
|
||||
try {
|
||||
setIsSubmittingBio(true);
|
||||
await onSaveBio(bio);
|
||||
setEnableBioEdit(false);
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
toast.error('个人简介更新失败');
|
||||
}
|
||||
finally {
|
||||
setIsSubmittingBio(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="icon-sm"
|
||||
variant={enableBioEdit ? 'default' : 'outline'}
|
||||
disabled={isSubmittingBio}
|
||||
>
|
||||
{isSubmittingBio ? <Spinner /> : <Pencil />}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/sidebar/app-sidebar.view.tsx
Normal file
45
src/components/sidebar/app-sidebar.view.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { NavData } from '@/lib/navData';
|
||||
import * as React from 'react';
|
||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
import { NavMain } from '@/components/sidebar/nav-main.view';
|
||||
import { NavSecondary } from '@/components/sidebar/nav-secondary.view';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function AppSidebar({ navData, footerWidget, secondaryNavExtra, ...props }: React.ComponentProps<typeof Sidebar> & { navData: NavData; footerWidget: React.ReactNode; secondaryNavExtra?: React.ReactNode }) {
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="data-[slot=sidebar-menu-button]:p-1.5!"
|
||||
>
|
||||
<a href="#">
|
||||
<NixOSLogo />
|
||||
<span className="text-base font-semibold">Nix CN CMS</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={navData.navMain} />
|
||||
<NavSecondary items={navData.navSecondary} className="mt-auto">
|
||||
{secondaryNavExtra}
|
||||
</NavSecondary>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{footerWidget}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
45
src/components/sidebar/nav-main.view.tsx
Normal file
45
src/components/sidebar/nav-main.view.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent className="flex flex-col gap-2">
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<Link
|
||||
to={item.url}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
50
src/components/sidebar/nav-secondary.view.tsx
Normal file
50
src/components/sidebar/nav-secondary.view.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import type { Icon } from '@tabler/icons-react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
children?: React.ReactNode;
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<Link to={item.url}>
|
||||
{({ isActive }) => {
|
||||
return (
|
||||
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}}
|
||||
</Link>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{children}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
11
src/components/sidebar/nav-user.container.tsx
Normal file
11
src/components/sidebar/nav-user.container.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||
import { NavUserView } from './nav-user.view';
|
||||
|
||||
export function NavUserContainer() {
|
||||
const { data } = useUserInfo();
|
||||
return (
|
||||
<NavUserView
|
||||
user={data.data!}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
src/components/sidebar/nav-user.skeletion.tsx
Normal file
18
src/components/sidebar/nav-user.skeletion.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { IconDotsVertical } from '@tabler/icons-react';
|
||||
import { SidebarMenuButton } from '../ui/sidebar';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
|
||||
export function NavUserSkeleton() {
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex flex-col flex-1 gap-1">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
92
src/components/sidebar/nav-user.view.tsx
Normal file
92
src/components/sidebar/nav-user.view.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ServiceUserUserInfoData } from '@/client';
|
||||
|
||||
import { identicon } from '@dicebear/collection';
|
||||
import { createAvatar } from '@dicebear/core';
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconLogout,
|
||||
} from '@tabler/icons-react';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { logout } from '@/lib/token';
|
||||
|
||||
export function NavUserView({ user }: { user: ServiceUserUserInfoData }) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
const IdentIcon = useMemo(() => {
|
||||
const avatar = createAvatar(identicon, {
|
||||
size: 128,
|
||||
seed: user.user_id,
|
||||
}).toDataUri();
|
||||
return <img src={avatar} alt="Avatar" />;
|
||||
}, [user.user_id]);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.nickname}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
<IconDotsVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
{!isEmpty(user.avatar) ? <AvatarImage src={user.avatar} alt={user.nickname} /> : IdentIcon}
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.nickname}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={_e => logout()}>
|
||||
<IconLogout />
|
||||
登出
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
17
src/components/site-header.tsx
Normal file
17
src/components/site-header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export function SiteHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{title}</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
52
src/components/theme-provider.tsx
Normal file
52
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Theme } from '@/hooks/useTheme';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ThemeProviderContext } from '@/hooks/useTheme';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'dark',
|
||||
storageKey = 'vite-ui-theme',
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProviderContext {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/avatar.tsx
Normal file
53
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
63
src/components/ui/button.tsx
Normal file
63
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } 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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
'default': 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
'sm': 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
'lg': 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
'icon': 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'>
|
||||
& VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
355
src/components/ui/chart.tsx
Normal file
355
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
310
src/components/ui/combobox.tsx
Normal file
310
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="combobox-trigger-icon"
|
||||
className="text-muted-foreground pointer-events-none size-4"
|
||||
/>
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
asChild
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxTrigger />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-sm" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
133
src/components/ui/drawer.tsx
Normal file
133
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
104
src/components/ui/empty.tsx
Normal file
104
src/components/ui/empty.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty"
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-header"
|
||||
className={cn(
|
||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-content"
|
||||
className={cn(
|
||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
EmptyMedia,
|
||||
}
|
||||
247
src/components/ui/field.tsx
Normal file
247
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||
horizontal: [
|
||||
'flex-row items-center',
|
||||
'[&>[data-slot=field-label]]:flex-auto',
|
||||
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
responsive: [
|
||||
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
|
||||
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map(error => [error?.message, error])).values(),
|
||||
];
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn('text-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
};
|
||||
168
src/components/ui/input-group.tsx
Normal file
168
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
75
src/components/ui/input-otp.tsx
Normal file
75
src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
188
src/components/ui/select.tsx
Normal file
188
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user