Commit 0831e1f4 authored by ThinhNC's avatar ThinhNC

feat: initialize data crawler fe with Next.js 16, Tailwind CSS, shadcn/ui,...

feat: initialize data crawler fe with Next.js 16, Tailwind CSS, shadcn/ui, TanStack Query, React Hook Form, Zod, and Axios
parent fa2d0326
# Backend API Base URL
NEXT_PUBLIC_API_URL=http://localhost:9999/api/v1
# App Settings
NEXT_PUBLIC_APP_NAME="Data Crawler System"
NEXT_PUBLIC_ENABLE_DEVTOOLS=true
...@@ -32,6 +32,7 @@ yarn-error.log* ...@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel
......
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Data Crawler Frontend (`data-crawler-fe`)
## Getting Started Dự án frontend chuyên dụng cho hệ thống **Data Crawler**, được xây dựng trên nền tảng **Next.js 16 (App Router)** tuân thủ cấu trúc chuẩn trong tài liệu [`project-structure.md`](./project-structure.md).
First, run the development server: ## 🚀 Tech Stack
```bash | Công nghệ | Vai trò |
npm run dev | :--- | :--- |
# or | **pnpm** | Package manager hiệu năng cao |
yarn dev | **Next.js 16** | Framework React với App Router (`src/app`) |
# or | **TypeScript** | Type-safety toàn diện |
pnpm dev | **Tailwind CSS v4** | Modern utility-first CSS & theme tokens |
# or | **shadcn/ui** | UI Component system (Radix UI primitives) |
bun dev | **TanStack Query v5** | Quản lý Server State, cache và auto-refetching |
``` | **React Hook Form + Zod** | Xử lý form và schema validation |
| **Axios** | HTTP Client với Interceptors và fallback mock layer |
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ---
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ## 📁 Cấu trúc thư mục (Project Structure)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ```
data-crawler-fe/
## Learn More ├── .env.example # Mẫu biến môi trường
├── .env.local # Biến môi trường chạy local
├── components.json # Cấu hình shadcn/ui
├── next.config.ts # Cấu hình Next.js
├── package.json # Dependencies & scripts
├── postcss.config.mjs # PostCSS
├── project-structure.md # Tài liệu kiến trúc Next.js App Router
├── tailwind.config.ts # Cấu hình Tailwind CSS
├── tsconfig.json # Cấu hình TypeScript với alias "@/*"
└── src/
├── app/ # Next.js App Router File Conventions
│ ├── error.tsx # Error boundary UI
│ ├── globals.css # Theme CSS tokens & Tailwind import
│ ├── layout.tsx # Root Layout bọc Providers & Navbar
│ ├── loading.tsx # Loading skeleton convention
│ ├── not-found.tsx # Trang 404 tùy chỉnh
│ └── page.tsx # Dashboard tổng quan Data Crawler
├── components/ # UI Components
│ ├── common/ # Navbar, StatCard, v.v.
│ ├── crawler/ # CrawlerTaskForm (RHF+Zod), CrawlerTaskTable
│ └── ui/ # shadcn/ui (button, input, badge, dialog, table, card)
├── hooks/ # Custom hooks (useCrawlerTasks, useCreateTask,...)
├── lib/ # Core Singletons: api-client (Axios), query-client, utils
├── providers/ # Providers (QueryClientProvider, Toaster)
├── schemas/ # Zod validation schemas (crawler.schema.ts)
└── types/ # TypeScript interfaces (api.ts, crawler.ts)
```
To learn more about Next.js, take a look at the following resources: ---
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## 🛠️ Hướng dẫn cài đặt & khởi chạy
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ### 1. Cài đặt dependencies
```bash
pnpm install
```
## Deploy on Vercel ### 2. Chạy môi trường phát triển (Development)
```bash
pnpm run dev
```
Mở trình duyệt tại: [http://localhost:3000](http://localhost:3000)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ### 3. Build Production
```bash
pnpm run build
pnpm run start
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### 4. Kiểm tra Linting & Type checking
```bash
pnpm run lint
```
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
...@@ -9,9 +9,24 @@ ...@@ -9,9 +9,24 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-slot": "^1.3.3",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-query-devtools": "^5.102.8",
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.41.0",
"next": "16.3.4", "next": "16.3.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8",
"react-hook-form": "^7.87.0",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"zod": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
......
...@@ -8,6 +8,39 @@ importers: ...@@ -8,6 +8,39 @@ importers:
.: .:
dependencies: dependencies:
'@hookform/resolvers':
specifier: ^5.9.1
version: 5.9.1(react-hook-form@7.87.0(react@19.2.8))(zod@4.5.4)
'@radix-ui/react-dialog':
specifier: ^1.1.23
version: 1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-label':
specifier: ^2.1.15
version: 2.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-select':
specifier: ^2.3.7
version: 2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot':
specifier: ^1.3.3
version: 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@tanstack/react-query':
specifier: ^5.102.8
version: 5.102.8(react@19.2.8)
'@tanstack/react-query-devtools':
specifier: ^5.102.8
version: 5.102.8(@tanstack/react-query@5.102.8(react@19.2.8))(react@19.2.8)
axios:
specifier: ^1.20.0
version: 1.20.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.2.8)
next: next:
specifier: 16.3.4 specifier: 16.3.4
version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.3.4(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
...@@ -17,6 +50,18 @@ importers: ...@@ -17,6 +50,18 @@ importers:
react-dom: react-dom:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8(react@19.2.8) version: 19.2.8(react@19.2.8)
react-hook-form:
specifier: ^7.87.0
version: 7.87.0(react@19.2.8)
sonner:
specifier: ^2.0.8
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
zod:
specifier: ^4.5.4
version: 4.5.4
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
...@@ -172,6 +217,99 @@ packages: ...@@ -172,6 +217,99 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/react-dom@2.1.9':
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@hookform/resolvers@5.9.1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
'@typeschema/main': '>=0.13.7'
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
ajv: ^8.12.0
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^1.2.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
effect: ^3.10.3
fluentvalidation-ts: ^3.0.0
fp-ts: ^2.7.0
io-ts: ^2.0.0
joi: ^17.0.0 || ^18.0.0
nope-validator: '>=0.12.0'
react-hook-form: ^7.55.0
superstruct: '>=0.12.0'
typanion: ^3.3.2
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
vest: '>=6.0.0'
yup: ^1.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@sinclair/typebox':
optional: true
'@standard-schema/spec':
optional: true
'@typeschema/main':
optional: true
'@vinejs/vine':
optional: true
ajv:
optional: true
ajv-errors:
optional: true
ajv-formats:
optional: true
arktype:
optional: true
ata-validator:
optional: true
class-transformer:
optional: true
class-validator:
optional: true
computed-types:
optional: true
effect:
optional: true
fluentvalidation-ts:
optional: true
fp-ts:
optional: true
io-ts:
optional: true
joi:
optional: true
nope-validator:
optional: true
superstruct:
optional: true
typanion:
optional: true
valibot:
optional: true
vest:
optional: true
yup:
optional: true
zod:
optional: true
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
...@@ -451,9 +589,294 @@ packages: ...@@ -451,9 +589,294 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'} engines: {node: '>=12.4.0'}
'@radix-ui/number@1.1.3':
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
'@radix-ui/primitive@1.1.7':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
'@radix-ui/react-arrow@1.1.15':
resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.15':
resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.5':
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.2.2':
resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.23':
resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.4':
resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.19':
resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.6':
resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-focus-scope@1.1.16':
resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.4':
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.15':
resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.3.7':
resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.17':
resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.10':
resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.10':
resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.3.7':
resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.3.3':
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-callback-ref@1.1.4':
resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.6':
resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.5':
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.4':
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.4':
resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.4':
resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.4':
resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.11':
resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.3':
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
'@rtsao/scc@1.1.0': '@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.23': '@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
...@@ -549,6 +972,23 @@ packages: ...@@ -549,6 +972,23 @@ packages:
'@tailwindcss/postcss@4.3.3': '@tailwindcss/postcss@4.3.3':
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
'@tanstack/query-core@5.102.8':
resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==}
'@tanstack/query-devtools@5.102.8':
resolution: {integrity: sha512-ZgeMKuF5d/zOE+tgWm3cSbD6Zcbr6IugVsnbHzUcSismqLZDSUPBKM6ILUxExgwe6rPAOox2x5bA5T+PSOQG0Q==}
'@tanstack/react-query-devtools@5.102.8':
resolution: {integrity: sha512-QKb7A44BZOU7nxsGA4gFN1fofjYovar5O0T83Ff4Y+2eRq09RGFrAzzutVdF/6/emfSaMDohp6e759BjB3fxEw==}
peerDependencies:
'@tanstack/react-query': ^5.102.8
react: ^18 || ^19
'@tanstack/react-query@5.102.8':
resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
...@@ -761,6 +1201,10 @@ packages: ...@@ -761,6 +1201,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv@6.15.0: ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
...@@ -771,6 +1215,10 @@ packages: ...@@ -771,6 +1215,10 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.2: aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -814,6 +1262,9 @@ packages: ...@@ -814,6 +1262,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -822,6 +1273,9 @@ packages: ...@@ -822,6 +1273,9 @@ packages:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'} engines: {node: '>=4'}
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
axobject-query@4.1.0: axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -877,9 +1331,16 @@ packages: ...@@ -877,9 +1331,16 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
...@@ -887,6 +1348,10 @@ packages: ...@@ -887,6 +1348,10 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
...@@ -943,10 +1408,17 @@ packages: ...@@ -943,10 +1408,17 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
doctrine@2.1.0: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -1174,10 +1646,23 @@ packages: ...@@ -1174,10 +1646,23 @@ packages:
flatted@3.4.4: flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.5: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
...@@ -1200,6 +1685,10 @@ packages: ...@@ -1200,6 +1685,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1: get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -1271,6 +1760,10 @@ packages: ...@@ -1271,6 +1760,10 @@ packages:
hermes-parser@0.25.1: hermes-parser@0.25.1:
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
...@@ -1548,6 +2041,11 @@ packages: ...@@ -1548,6 +2041,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.41.0:
resolution: {integrity: sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
...@@ -1563,6 +2061,14 @@ packages: ...@@ -1563,6 +2061,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@10.2.6: minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
...@@ -1711,6 +2217,10 @@ packages: ...@@ -1711,6 +2217,10 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
...@@ -1723,9 +2233,45 @@ packages: ...@@ -1723,9 +2233,45 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.8 react: ^19.2.8
react-hook-form@7.87.0:
resolution: {integrity: sha512-zhFzWvLxNHH+8839OnZcUxgMZw88ah2jZWDWvKWgF3Tpbnd0vKL+dlcuU3nZVWESZQjd81EW8K+wU+cYfYAc0w==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-remove-scroll@2.7.2:
resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
react@19.2.8: react@19.2.8:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -1826,6 +2372,16 @@ packages: ...@@ -1826,6 +2372,16 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
sonner@2.0.8:
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -1889,6 +2445,9 @@ packages: ...@@ -1889,6 +2445,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.3: tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
...@@ -1967,6 +2526,26 @@ packages: ...@@ -1967,6 +2526,26 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
peerDependencies:
'@types/react': '*'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
...@@ -2184,6 +2763,30 @@ snapshots: ...@@ -2184,6 +2763,30 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/dom': 1.8.0
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
'@floating-ui/utils@0.2.12': {}
'@hookform/resolvers@5.9.1(react-hook-form@7.87.0(react@19.2.8))(zod@4.5.4)':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.87.0(react@19.2.8)
optionalDependencies:
zod: 4.5.4
'@humanfs/core@0.19.2': '@humanfs/core@0.19.2':
dependencies: dependencies:
'@humanfs/types': 0.15.0 '@humanfs/types': 0.15.0
...@@ -2380,8 +2983,264 @@ snapshots: ...@@ -2380,8 +2983,264 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {} '@nolyfill/is-core-module@1.0.39': {}
'@radix-ui/number@1.1.3': {}
'@radix-ui/primitive@1.1.7': {}
'@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
aria-hidden: 1.2.6
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-label@2.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/rect': 1.1.3
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-select@2.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/number': 1.1.3
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
aria-hidden: 1.2.6
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8)
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/rect': 1.1.3
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8)
react: 19.2.8
optionalDependencies:
'@types/react': 19.2.18
'@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.7(@types/react@19.2.18)
'@radix-ui/rect@1.1.3': {}
'@rtsao/scc@1.1.0': {} '@rtsao/scc@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.23': '@swc/helpers@0.5.23':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
...@@ -2455,6 +3314,21 @@ snapshots: ...@@ -2455,6 +3314,21 @@ snapshots:
postcss: 8.5.28 postcss: 8.5.28
tailwindcss: 4.3.3 tailwindcss: 4.3.3
'@tanstack/query-core@5.102.8': {}
'@tanstack/query-devtools@5.102.8': {}
'@tanstack/react-query-devtools@5.102.8(@tanstack/react-query@5.102.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@tanstack/query-devtools': 5.102.8
'@tanstack/react-query': 5.102.8(react@19.2.8)
react: 19.2.8
'@tanstack/react-query@5.102.8(react@19.2.8)':
dependencies:
'@tanstack/query-core': 5.102.8
react: 19.2.8
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
...@@ -2645,6 +3519,12 @@ snapshots: ...@@ -2645,6 +3519,12 @@ snapshots:
acorn@8.18.0: {} acorn@8.18.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv@6.15.0: ajv@6.15.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
...@@ -2658,6 +3538,10 @@ snapshots: ...@@ -2658,6 +3538,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.2: {} aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
...@@ -2731,12 +3615,24 @@ snapshots: ...@@ -2731,12 +3615,24 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axe-core@4.13.0: {} axe-core@4.13.0: {}
axios@1.20.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
axobject-query@4.1.0: {} axobject-query@4.1.0: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
...@@ -2792,14 +3688,24 @@ snapshots: ...@@ -2792,14 +3688,24 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
client-only@0.0.1: {} client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {} concat-map@0.0.1: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
...@@ -2854,8 +3760,12 @@ snapshots: ...@@ -2854,8 +3760,12 @@ snapshots:
has-property-descriptors: 1.0.2 has-property-descriptors: 1.0.2
object-keys: 1.1.1 object-keys: 1.1.1
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
doctrine@2.1.0: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
...@@ -3237,10 +4147,20 @@ snapshots: ...@@ -3237,10 +4147,20 @@ snapshots:
flatted@3.4.4: {} flatted@3.4.4: {}
follow-redirects@1.16.0: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
function-bind@1.1.2: {} function-bind@1.1.2: {}
function.prototype.name@1.2.0: function.prototype.name@1.2.0:
...@@ -3274,6 +4194,8 @@ snapshots: ...@@ -3274,6 +4194,8 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
math-intrinsics: 1.1.0 math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-proto@1.0.1: get-proto@1.0.1:
dependencies: dependencies:
dunder-proto: 1.0.1 dunder-proto: 1.0.1
...@@ -3338,6 +4260,13 @@ snapshots: ...@@ -3338,6 +4260,13 @@ snapshots:
dependencies: dependencies:
hermes-estree: 0.25.1 hermes-estree: 0.25.1
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.8: {} ignore@7.0.8: {}
...@@ -3591,6 +4520,10 @@ snapshots: ...@@ -3591,6 +4520,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.41.0(react@19.2.8):
dependencies:
react: 19.2.8
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/sourcemap-codec': 1.6.0
...@@ -3604,6 +4537,12 @@ snapshots: ...@@ -3604,6 +4537,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.2 picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.6: minimatch@10.2.6:
dependencies: dependencies:
brace-expansion: 5.0.9 brace-expansion: 5.0.9
...@@ -3760,6 +4699,8 @@ snapshots: ...@@ -3760,6 +4699,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {} punycode@2.3.1: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
...@@ -3769,8 +4710,39 @@ snapshots: ...@@ -3769,8 +4710,39 @@ snapshots:
react: 19.2.8 react: 19.2.8
scheduler: 0.27.0 scheduler: 0.27.0
react-hook-form@7.87.0(react@19.2.8):
dependencies:
react: 19.2.8
react-is@16.13.1: {} react-is@16.13.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8):
dependencies:
react: 19.2.8
react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8)
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.18
react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8):
dependencies:
react: 19.2.8
react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8)
react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8)
tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8)
use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8):
dependencies:
get-nonce: 1.0.1
react: 19.2.8
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.18
react@19.2.8: {} react@19.2.8: {}
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
...@@ -3927,6 +4899,13 @@ snapshots: ...@@ -3927,6 +4899,13 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
sonner@2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
...@@ -4004,6 +4983,8 @@ snapshots: ...@@ -4004,6 +4983,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {} tailwindcss@4.3.3: {}
tapable@2.3.3: {} tapable@2.3.3: {}
...@@ -4126,6 +5107,21 @@ snapshots: ...@@ -4126,6 +5107,21 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8):
dependencies:
react: 19.2.8
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.18
use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8):
dependencies:
detect-node-es: 1.1.0
react: 19.2.8
tslib: 2.8.1
optionalDependencies:
'@types/react': 19.2.18
which-boxed-primitive@1.1.1: which-boxed-primitive@1.1.1:
dependencies: dependencies:
is-bigint: 1.1.0 is-bigint: 1.1.0
......
---
title: Project structure and organization
description: Learn the folder and file conventions in Next.js, and how to organize your project.
url: "https://nextjs.org/docs/app/getting-started/project-structure"
docs_index: /docs/llms.txt
version: 16.3.4
lastUpdated: 2026-07-21
prerequisites:
- "Getting Started: /docs/app/getting-started"
---
> For an index of all Next.js documentation, see [/docs/llms.txt](/docs/llms.txt).
This page provides an overview of **all** the folder and file conventions in Next.js, and recommendations for organizing your project.
## Folder and file conventions
### Top-level folders
Top-level folders are used to organize your application's code and static assets.
![Route segments to path segments](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/top-level-folders.png)
| | |
| ------------------------------------------------------------------ | ---------------------------------- |
| [`app`](/docs/app) | App Router |
| [`pages`](/docs/pages/building-your-application/routing) | Pages Router |
| [`public`](/docs/app/api-reference/file-conventions/public-folder) | Static assets to be served |
| [`src`](/docs/app/api-reference/file-conventions/src-folder) | Optional application source folder |
### Top-level files
Top-level files are used to configure your application, manage dependencies, run proxy, integrate monitoring tools, and define environment variables.
| | |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| **Next.js** | |
| [`next.config.js`](/docs/app/api-reference/config/next-config-js) | Configuration file for Next.js |
| [`package.json`](/docs/app/getting-started/installation#manual-installation) | Project dependencies and scripts |
| [`instrumentation.ts`](/docs/app/guides/instrumentation) | OpenTelemetry and Instrumentation file |
| [`proxy.ts`](/docs/app/api-reference/file-conventions/proxy) | Next.js request proxy |
| [`.env`](/docs/app/guides/environment-variables) | Environment variables (should not be tracked by version control) |
| [`.env.local`](/docs/app/guides/environment-variables) | Local environment variables (should not be tracked by version control) |
| [`.env.production`](/docs/app/guides/environment-variables) | Production environment variables (should not be tracked by version control) |
| [`.env.development`](/docs/app/guides/environment-variables) | Development environment variables (should not be tracked by version control) |
| [`eslint.config.mjs`](/docs/app/api-reference/config/eslint) | Configuration file for ESLint |
| `.gitignore` | Git files and folders to ignore |
| [`next-env.d.ts`](/docs/app/api-reference/config/typescript#next-envdts) | TypeScript declaration file for Next.js (should not be tracked by version control) |
| `tsconfig.json` | Configuration file for TypeScript |
| `jsconfig.json` | Configuration file for JavaScript |
### Routing Files
Add `page` to expose a route, `layout` for shared UI such as header, nav, or footer, `loading` for skeletons, `error` for error boundaries, and `route` for APIs.
| | | |
| ----------------------------------------------------------------------------- | ------------------- | ---------------------------- |
| [`layout`](/docs/app/api-reference/file-conventions/layout) | `.js` `.jsx` `.tsx` | Layout |
| [`page`](/docs/app/api-reference/file-conventions/page) | `.js` `.jsx` `.tsx` | Page |
| [`loading`](/docs/app/api-reference/file-conventions/loading) | `.js` `.jsx` `.tsx` | Loading UI |
| [`not-found`](/docs/app/api-reference/file-conventions/not-found) | `.js` `.jsx` `.tsx` | Not found UI |
| [`error`](/docs/app/api-reference/file-conventions/error) | `.js` `.jsx` `.tsx` | Error UI |
| [`global-error`](/docs/app/api-reference/file-conventions/error#global-error) | `.js` `.jsx` `.tsx` | Global error UI |
| [`route`](/docs/app/api-reference/file-conventions/route) | `.js` `.ts` | API endpoint |
| [`template`](/docs/app/api-reference/file-conventions/template) | `.js` `.jsx` `.tsx` | Re-rendered layout |
| [`default`](/docs/app/api-reference/file-conventions/default) | `.js` `.jsx` `.tsx` | Parallel route fallback page |
### Nested routes
Folders define URL segments. Nesting folders nests segments. Layouts at any level wrap their child segments. A route becomes public when a `page` or `route` file exists.
| Path | URL pattern | Notes |
| --------------------------- | --------------- | ----------------------------- |
| `app/layout.tsx` | — | Root layout wraps all routes |
| `app/blog/layout.tsx` | — | Wraps `/blog` and descendants |
| `app/page.tsx` | `/` | Public route |
| `app/blog/page.tsx` | `/blog` | Public route |
| `app/blog/authors/page.tsx` | `/blog/authors` | Public route |
### Dynamic routes
Parameterize segments with square brackets. Use `[segment]` for a single param, `[...segment]` for catch‑all, and `[[...segment]]` for optional catch‑all. Access values via the [`params`](/docs/app/api-reference/file-conventions/page#params-optional) prop.
| Path | URL pattern |
| ------------------------------- | -------------------------------------------------------------------- |
| `app/blog/[slug]/page.tsx` | `/blog/my-first-post` |
| `app/shop/[...slug]/page.tsx` | `/shop/clothing`, `/shop/clothing/shirts` |
| `app/docs/[[...slug]]/page.tsx` | `/docs`, `/docs/layouts-and-pages`, `/docs/api-reference/use-router` |
### Route groups and private folders
Organize code without changing URLs with route groups [`(group)`](/docs/app/api-reference/file-conventions/route-groups#convention), and colocate non-routable files with private folders [`_folder`](#private-folders).
| Path | URL pattern | Notes |
| ------------------------------- | ----------- | ----------------------------------------- |
| `app/(marketing)/page.tsx` | `/` | Group omitted from URL |
| `app/(shop)/cart/page.tsx` | `/cart` | Share layouts within `(shop)` |
| `app/blog/_components/Post.tsx` | — | Not routable; safe place for UI utilities |
| `app/blog/_lib/data.ts` | — | Not routable; safe place for utils |
### Parallel and Intercepted Routes
These features fit specific UI patterns, such as slot-based layouts or modal routing.
Use `@slot` for named slots rendered by a parent layout. Use intercept patterns to render another route inside the current layout without changing the URL, for example, to show a details view as a modal over a list.
| Pattern (docs) | Meaning | Typical use case |
| ------------------------------------------------------------------------------------------- | -------------------- | ---------------------------------------- |
| [`@folder`](/docs/app/api-reference/file-conventions/parallel-routes#slots) | Named slot | Sidebar + main content |
| [`(.)folder`](/docs/app/api-reference/file-conventions/intercepting-routes#convention) | Intercept same level | Preview sibling route in a modal |
| [`(..)folder`](/docs/app/api-reference/file-conventions/intercepting-routes#convention) | Intercept parent | Open a child of the parent as an overlay |
| [`(..)(..)folder`](/docs/app/api-reference/file-conventions/intercepting-routes#convention) | Intercept two levels | Deeply nested overlay |
| [`(...)folder`](/docs/app/api-reference/file-conventions/intercepting-routes#convention) | Intercept from root | Show arbitrary route in current view |
### Metadata file conventions
#### App icons
| | | |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------------------------ |
| [`favicon`](/docs/app/api-reference/file-conventions/metadata/app-icons#favicon) | `.ico` | Favicon file |
| [`icon`](/docs/app/api-reference/file-conventions/metadata/app-icons#icon) | `.ico` `.jpg` `.jpeg` `.png` `.svg` | App Icon file |
| [`icon`](/docs/app/api-reference/file-conventions/metadata/app-icons#generate-icons-using-code-js-ts-tsx) | `.js` `.ts` `.tsx` | Generated App Icon |
| [`apple-icon`](/docs/app/api-reference/file-conventions/metadata/app-icons#apple-icon) | `.jpg` `.jpeg` `.png` | Apple App Icon file |
| [`apple-icon`](/docs/app/api-reference/file-conventions/metadata/app-icons#generate-icons-using-code-js-ts-tsx) | `.js` `.ts` `.tsx` | Generated Apple App Icon |
#### Open Graph and Twitter images
| | | |
| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | -------------------------- |
| [`opengraph-image`](/docs/app/api-reference/file-conventions/metadata/opengraph-image#opengraph-image) | `.jpg` `.jpeg` `.png` `.gif` | Open Graph image file |
| [`opengraph-image`](/docs/app/api-reference/file-conventions/metadata/opengraph-image#generate-images-using-code-js-ts-tsx) | `.js` `.ts` `.tsx` | Generated Open Graph image |
| [`twitter-image`](/docs/app/api-reference/file-conventions/metadata/opengraph-image#twitter-image) | `.jpg` `.jpeg` `.png` `.gif` | Twitter image file |
| [`twitter-image`](/docs/app/api-reference/file-conventions/metadata/opengraph-image#generate-images-using-code-js-ts-tsx) | `.js` `.ts` `.tsx` | Generated Twitter image |
#### SEO
| | | |
| ------------------------------------------------------------------------------------------------------------ | ----------- | --------------------- |
| [`sitemap`](/docs/app/api-reference/file-conventions/metadata/sitemap#sitemap-files-xml) | `.xml` | Sitemap file |
| [`sitemap`](/docs/app/api-reference/file-conventions/metadata/sitemap#generating-a-sitemap-using-code-js-ts) | `.js` `.ts` | Generated Sitemap |
| [`robots`](/docs/app/api-reference/file-conventions/metadata/robots#static-robotstxt) | `.txt` | Robots file |
| [`robots`](/docs/app/api-reference/file-conventions/metadata/robots#generate-a-robots-file) | `.js` `.ts` | Generated Robots file |
## Organizing your project
Next.js is **unopinionated** about how you organize and colocate your project files. But it does provide several features to help you organize your project.
### Component hierarchy
The components defined in special files are rendered in a specific hierarchy:
* `layout.js`
* `template.js`
* `error.js` (React error boundary)
* `loading.js` (React suspense boundary)
* `not-found.js` (React error boundary for "not found" UI)
* `page.js` or nested `layout.js`
![Component Hierarchy for File Conventions](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/file-conventions-component-hierarchy.png)
The components are rendered recursively in nested routes, meaning the components of a route segment will be nested **inside** the components of its parent segment.
![Nested File Conventions Component Hierarchy](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/nested-file-conventions-component-hierarchy.png)
### Colocation
In the `app` directory, nested folders define route structure. Each folder represents a route segment that is mapped to a corresponding segment in a URL path.
However, even though route structure is defined through folders, a route is **not publicly accessible** until a `page.js` or `route.js` file is added to a route segment.
![A diagram showing how a route is not publicly accessible until a page.js or route.js file is added to a route segment.](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-not-routable.png)
And, even when a route is made publicly accessible, only the **content returned** by `page.js` or `route.js` is sent to the client.
![A diagram showing how page.js and route.js files make routes publicly accessible.](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-routable.png)
This means that **project files** can be **safely colocated** inside route segments in the `app` directory without accidentally being routable.
![A diagram showing colocated project files are not routable even when a segment contains a page.js or route.js file.](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-colocation.png)
> **Good to know**: While you **can** colocate your project files in `app` you don't **have** to. If you prefer, you can [keep them outside the `app` directory](#store-project-files-outside-of-app).
### Private folders
Private folders can be created by prefixing a folder with an underscore: `_folderName`
This indicates the folder is a private implementation detail and should not be considered by the routing system, thereby **opting the folder and all its subfolders** out of routing.
![An example folder structure using private folders](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-private-folders.png)
Since files in the `app` directory can be [safely colocated by default](#colocation), private folders are not required for colocation. However, they can be useful for:
* Separating UI logic from routing logic.
* Consistently organizing internal files across a project and the Next.js ecosystem.
* Sorting and grouping files in code editors.
* Avoiding potential naming conflicts with future Next.js file conventions.
> **Good to know**:
>
> * While not a framework convention, you might also consider marking files outside private folders as "private" using the same underscore pattern.
> * You can create URL segments that start with an underscore by prefixing the folder name with `%5F` (the URL-encoded form of an underscore): `%5FfolderName`.
> * If you don't use private folders, it would be helpful to know Next.js [special file conventions](/docs/app/getting-started/project-structure#routing-files) to prevent unexpected naming conflicts.
### Route groups
Route groups can be created by wrapping a folder in parenthesis: `(folderName)`
This indicates the folder is for organizational purposes and should **not be included** in the route's URL path.
![An example folder structure using route groups](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-route-groups.png)
Route groups are useful for:
* Organizing routes by site section, intent, or team. e.g. marketing pages, admin pages, etc.
* Enabling nested layouts in the same route segment level:
* [Creating multiple nested layouts in the same segment, including multiple root layouts](#creating-multiple-root-layouts)
* [Adding a layout to a subset of routes in a common segment](#opting-specific-segments-into-a-layout)
### `src` folder
Next.js supports storing application code (including `app`) inside an optional [`src` folder](/docs/app/api-reference/file-conventions/src-folder). This separates application code from project configuration files which mostly live in the root of a project.
![An example folder structure with the src folder](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-src-directory.png)
## Examples
The following section lists a very high-level overview of common strategies. The simplest takeaway is to choose a strategy that works for you and your team and be consistent across the project.
> **Good to know**: In our examples below, we're using `components` and `lib` folders as generalized placeholders, their naming has no special framework significance and your projects might use other folders like `ui`, `utils`, `hooks`, `styles`, etc.
### Store project files outside of `app`
This strategy stores all application code in shared folders in the **root of your project** and keeps the `app` directory purely for routing purposes.
![An example folder structure with project files outside of app](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-project-root.png)
### Store project files in top-level folders inside of `app`
This strategy stores all application code in shared folders in the **root of the `app` directory**.
![An example folder structure with project files inside app](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-app-root.png)
### Split project files by feature or route
This strategy stores globally shared application code in the root `app` directory and **splits** more specific application code into the route segments that use them.
![An example folder structure with project files split by feature or route](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/project-organization-app-root-split.png)
### Organize routes without affecting the URL path
To organize routes without affecting the URL, create a group to keep related routes together. The folders in parenthesis will be omitted from the URL (e.g. `(marketing)` or `(shop)`).
![Organizing Routes with Route Groups](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/route-group-organisation.png)
Even though routes inside `(marketing)` and `(shop)` share the same URL hierarchy, you can create a different layout for each group by adding a `layout.js` file inside their folders. These layouts nest within the existing app layout.
![Route Groups with Multiple Layouts](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/route-group-multiple-layouts.png)
### Opting specific segments into a layout
To opt specific routes into a layout, create a new route group (e.g. `(shop)`) and move the routes that share the same layout into the group (e.g. `account` and `cart`). The routes outside of the group will not share the layout (e.g. `checkout`).
![Route Groups with Opt-in Layouts](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/route-group-opt-in-layouts.png)
### Opting for loading skeletons on a specific route
To apply a [loading skeleton](/docs/app/api-reference/file-conventions/loading) via a `loading.js` file to a specific route, create a new route group (e.g., `/(overview)`) and then move your `loading.tsx` inside that route group.
![Folder structure showing a loading.tsx and a page.tsx inside the route group](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/route-group-loading.png)
Now, the `loading.tsx` file will only apply to your dashboard → overview page instead of all your dashboard pages without affecting the URL path structure.
### Creating multiple root layouts
To create multiple [root layouts](/docs/app/api-reference/file-conventions/layout#root-layout), remove the top-level `layout.js` file, and add a `layout.js` file inside each route group. This is useful for partitioning an application into sections that have a completely different UI or experience. The `<html>` and `<body>` tags need to be added to each root layout.
![Route Groups with Multiple Root Layouts](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/docs/light/route-group-multiple-root-layouts.png)
In the example above, both `(marketing)` and `(shop)` have their own root layout.
---
For a semantic overview of all documentation, see [/docs/sitemap.md](/docs/sitemap.md)
For an index of all available documentation, see [/docs/llms.txt](/docs/llms.txt)
"use client";
import React, { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw } from "lucide-react";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("App Error Boundary caught:", error);
}, [error]);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 mb-4">
<AlertTriangle className="h-8 w-8" />
</div>
<h2 className="text-xl font-bold text-slate-100">Đã xảy ra sự cố không mong muốn</h2>
<p className="mt-2 text-sm text-slate-400 max-w-md">
{error?.message || "Hệ thống gặp lỗi khi tải trang này. Bạn có thể thử khôi phục lại."}
</p>
<div className="mt-6 flex items-center gap-3">
<Button onClick={() => reset()} className="bg-emerald-600 hover:bg-emerald-500">
<RefreshCw className="mr-2 h-4 w-4" />
Thử Lại
</Button>
<Button variant="outline" onClick={() => (window.location.href = "/")}>
Về Trang Chủ
</Button>
</div>
</div>
);
}
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #090d16;
--foreground: #171717; --foreground: #f1f5f9;
--card: #0f172a;
--card-foreground: #f1f5f9;
--popover: #0f172a;
--popover-foreground: #f1f5f9;
--primary: #10b981;
--primary-foreground: #ffffff;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #1e293b;
--input: #1e293b;
--ring: #10b981;
--radius: 0.75rem;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --color-card: var(--card);
--font-mono: var(--font-geist-mono); --color-card-foreground: var(--card-foreground);
} --color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
@media (prefers-color-scheme: dark) { --color-primary: var(--primary);
:root { --color-primary-foreground: var(--primary-foreground);
--background: #0a0a0a; --color-secondary: var(--secondary);
--foreground: #ededed; --color-secondary-foreground: var(--secondary-foreground);
} --color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), monospace;
} }
body { body {
background: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: var(--font-sans);
min-height: 100vh;
background-image:
radial-gradient(at 10% 10%, rgba(16, 185, 129, 0.08) 0px, transparent 40%),
radial-gradient(at 90% 90%, rgba(6, 182, 212, 0.08) 0px, transparent 40%);
background-attachment: fixed;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #090d16;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
} }
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Providers } from "@/providers/providers";
import { Navbar } from "@/components/common/navbar";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
...@@ -13,17 +15,31 @@ const geistMono = Geist_Mono({ ...@@ -13,17 +15,31 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Data Crawler System | Nền Tảng Thu Thập Dữ Liệu Tự Động",
description: "Generated by create next app", description:
"Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime, tích hợp Next.js, TanStack Query, React Hook Form và Tailwind CSS.",
}; };
export default function RootLayout({ children }: LayoutProps<"/">) { export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html <html lang="vi" className="dark">
lang="en" <body
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col bg-[#090d16] text-slate-100 selection:bg-emerald-500/30 selection:text-emerald-300`}
> >
<body className="min-h-full flex flex-col">{children}</body> <Providers>
<Navbar />
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children}
</main>
<footer className="border-t border-slate-800/60 py-6 text-center text-xs text-slate-500">
<p>Data Crawler Architecture © {new Date().getFullYear()} — Powered by Next.js & TanStack Query</p>
</footer>
</Providers>
</body>
</html> </html>
); );
} }
import React from "react";
import { Loader2 } from "lucide-react";
export default function Loading() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
<div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-emerald-500/20 border-t-emerald-500 animate-spin" />
<Loader2 className="absolute inset-0 m-auto h-5 w-5 text-emerald-400 animate-pulse" />
</div>
<p className="text-sm font-medium text-slate-400">Đang chuẩn bị dữ liệu...</p>
</div>
);
}
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Compass, Home } from "lucide-react";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-800/80 border border-slate-700 text-emerald-400 mb-4">
<Compass className="h-8 w-8 animate-pulse" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-white">404</h1>
<h2 className="mt-2 text-lg font-semibold text-slate-200">Không tìm thấy trang yêu cầu</h2>
<p className="mt-1 text-sm text-slate-400 max-w-sm">
Đường dẫn bạn truy cập không tồn tại hoặc đã được di chuyển sang địa chỉ khác.
</p>
<div className="mt-6">
<Button asChild className="bg-emerald-600 hover:bg-emerald-500">
<Link href="/">
<Home className="mr-2 h-4 w-4" />
Về Dashboard
</Link>
</Button>
</div>
</div>
);
}
import Image from "next/image"; "use client";
import React, { useState } from "react";
import { useCrawlerStats } from "@/hooks/use-crawler";
import { StatCard } from "@/components/common/stat-card";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Activity,
CheckCircle2,
Database,
FileCode2,
Globe2,
Plus,
Radio,
Server,
Sparkles,
} from "lucide-react";
export default function DashboardPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { data: stats, isLoading: isStatsLoading } = useCrawlerStats();
export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="space-y-8">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> {/* Top Banner / Hero */}
<Image <div className="relative overflow-hidden rounded-2xl border border-slate-800 bg-gradient-to-r from-slate-900 via-slate-900/90 to-slate-950 p-6 sm:p-8 shadow-2xl">
className="dark:invert h-5 w-[100px]" <div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
src="/next.svg" <div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
alt="Next.js logo" <div className="space-y-2">
width={100} <div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-400">
height={20} <Sparkles className="h-3.5 w-3.5" />
priority Next.js 16 + TanStack Query + shadcn/ui Architecture
/> </div>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> Data Crawler Management
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1> </h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> <p className="text-sm text-slate-400 max-w-2xl">
Looking for a starting point or more instructions? Head over to{" "} Hệ thống quản lý, giám sát và bóc tách dữ liệu đa nguồn theo thời gian thực.
<a Được thiết kế theo cấu trúc App Router chuẩn và tối ưu hóa hiệu năng state management.
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p> </p>
</div> </div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a <div className="flex items-center gap-3 shrink-0">
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <DialogTrigger asChild>
target="_blank" <Button className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-lg shadow-emerald-600/20 px-5 py-2.5 h-auto cursor-pointer">
rel="noopener noreferrer" <Plus className="mr-2 h-4 w-4" />
> Tạo Tác Vụ Mới
<Image </Button>
className="dark:invert h-[14px] w-4" </DialogTrigger>
src="/vercel.svg" <DialogContent className="sm:max-w-md">
alt="Vercel logomark" <DialogHeader>
width={16} <DialogTitle className="flex items-center gap-2">
height={14} <Globe2 className="h-5 w-5 text-emerald-400" />
Tạo Tác Vụ Cào Dữ Liệu
</DialogTitle>
<DialogDescription>
Điền thông tin URL và tiêu chí bóc tách. Form được xác thực tự động bởi React Hook Form + Zod.
</DialogDescription>
</DialogHeader>
<CrawlerTaskForm onSuccess={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tác vụ đang chạy"
value={isStatsLoading ? "..." : (stats?.activeTasks ?? 0)}
description="Đang cào dữ liệu song song"
icon={Radio}
trend={{ value: "Realtime", isPositive: true }}
colorClassName="from-emerald-500/20 to-teal-500/5 text-emerald-400 border-emerald-500/30"
/>
<StatCard
title="Trang web đã cào"
value={isStatsLoading ? "..." : (stats?.totalPagesCrawled ?? 0).toLocaleString()}
description="Lưu lượng trang đã duyệt"
icon={Activity}
trend={{ value: "14.2%", isPositive: true }}
colorClassName="from-cyan-500/20 to-blue-500/5 text-cyan-400 border-cyan-500/30"
/>
<StatCard
title="Mục dữ liệu bóc tách"
value={isStatsLoading ? "..." : (stats?.totalItemsExtracted ?? 0).toLocaleString()}
description="Bản ghi trích xuất thành công"
icon={Database}
trend={{ value: "28.5%", isPositive: true }}
colorClassName="from-indigo-500/20 to-purple-500/5 text-indigo-400 border-indigo-500/30"
/>
<StatCard
title="Tỷ lệ thành công"
value={isStatsLoading ? "..." : `${stats?.successRate ?? 98.4}%`}
description="Độ tin cậy xử lý HTTP requests"
icon={CheckCircle2}
trend={{ value: "0.8%", isPositive: true }}
colorClassName="from-teal-500/20 to-emerald-500/5 text-teal-400 border-teal-500/30"
/> />
Deploy Now </div>
</a>
<a {/* Main Content: Tasks Table */}
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]" <div className="space-y-4" id="tasks">
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" <div className="flex items-center justify-between">
target="_blank" <div>
rel="noopener noreferrer" <h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
> <Server className="h-5 w-5 text-emerald-400" />
Documentation Danh Sách Tác Vụ Cào Dữ Liệu
</a> </h2>
</div> <p className="text-xs text-slate-400">
</main> Cập nhật tự động mỗi 5s bằng TanStack Query (Polling / Cache sync)
</p>
</div>
</div>
<CrawlerTaskTable />
</div>
{/* Architecture & Tech Stack Highlights */}
<div className="rounded-xl border border-slate-800/80 bg-slate-900/40 p-6 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-200 mb-4">
<FileCode2 className="h-4 w-4 text-emerald-400" />
Tổng Quan Kiến Trúc Đã Khởi Tạo
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-slate-300">
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5">
<span className="font-semibold text-emerald-400">Next.js App Router (src/app)</span>
<p className="text-slate-400">
Sử dụng đầy đủ layout, loading skeletons, error boundaries, và not-found page theo tài liệu <code className="text-slate-300">project-structure.md</code>.
</p>
</div>
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5">
<span className="font-semibold text-cyan-400">State & Validation Stack</span>
<p className="text-slate-400">
TanStack Query v5 (React Query) quản lý server state và auto-refetch, React Hook Form kết hợp Zod schema đảm bảo type-safety hoàn hảo.
</p>
</div>
<div className="space-y-1.5 rounded-lg border border-slate-800 bg-slate-900/60 p-3.5">
<span className="font-semibold text-teal-400">Axios Client & Fallback Layer</span>
<p className="text-slate-400">
Axios instance tích hợp Interceptor đính kèm Token, bắt lỗi ApiError chuẩn hóa và hỗ trợ mock fallback để frontend chạy ngay lập tức.
</p>
</div>
</div>
</div>
</div> </div>
); );
} }
"use client";
import React from "react";
import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react";
export function Navbar() {
return (
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/80 backdrop-blur-md">
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
{/* Brand */}
<Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-600 shadow-md shadow-emerald-500/20">
<Bot className="h-6 w-6 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-white">DataCrawler</span>
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 border border-emerald-500/20">
PRO v1.0
</span>
</div>
<p className="text-xs text-slate-400">Hệ thống cào và xử lý dữ liệu tự động</p>
</div>
</Link>
{/* Center Nav Links */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-slate-300">
<Link
href="/"
className="flex items-center gap-1.5 text-emerald-400 hover:text-emerald-300 transition-colors"
>
<Activity className="h-4 w-4" />
Dashboard
</Link>
<Link
href="#tasks"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors"
>
<Globe className="h-4 w-4" />
Tác vụ Crawl
</Link>
<Link
href="#storage"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors"
>
<Database className="h-4 w-4" />
Kho Dữ Liệu
</Link>
</nav>
{/* Right Status */}
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs text-slate-300">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
<span>Crawler Engine Active</span>
</div>
<button
onClick={() => window.location.reload()}
title="Tải lại trang"
className="rounded-lg border border-slate-800 bg-slate-900/50 p-2 text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
);
}
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatCardProps {
title: string;
value: string | number;
description?: string;
icon: LucideIcon;
trend?: {
value: string;
isPositive: boolean;
};
colorClassName?: string;
}
export function StatCard({
title,
value,
description,
icon: Icon,
trend,
colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-400 border-emerald-500/30",
}: StatCardProps) {
return (
<Card className="relative overflow-hidden border border-slate-800/80 bg-slate-900/60 backdrop-blur-md">
{/* Subtle Glow background */}
<div
className={cn(
"absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl",
colorClassName
)}
/>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">
{title}
</p>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border bg-gradient-to-br",
colorClassName
)}
>
<Icon className="h-5 w-5" />
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold tracking-tight text-white">
{value}
</span>
{trend && (
<span
className={cn(
"text-xs font-semibold",
trend.isPositive ? "text-emerald-400" : "text-red-400"
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}
</span>
)}
</div>
{description && (
<p className="mt-1 text-xs text-slate-400">{description}</p>
)}
</CardContent>
</Card>
);
}
"use client";
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createCrawlerTaskSchema,
CreateCrawlerTaskInput,
} from "@/schemas/crawler.schema";
import { useCreateCrawlerTask } from "@/hooks/use-crawler";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, PlusCircle, Sparkles } from "lucide-react";
interface CrawlerTaskFormProps {
onSuccess?: () => void;
}
export function CrawlerTaskForm({ onSuccess }: CrawlerTaskFormProps) {
const { mutate: createTask, isPending } = useCreateCrawlerTask();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateCrawlerTaskInput>({
resolver: zodResolver(createCrawlerTaskSchema),
defaultValues: {
name: "",
targetUrl: "",
maxDepth: 2,
maxPages: 50,
rateLimit: 1000,
extractSelectors: "h1, .content, article",
},
});
const onSubmit = (data: CreateCrawlerTaskInput) => {
createTask(data, {
onSuccess: () => {
reset();
onSuccess?.();
},
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Task Name */}
<div className="space-y-1.5">
<Label htmlFor="name">Tên Tác Vụ Crawl</Label>
<Input
id="name"
placeholder="vd: Cào bài viết công nghệ hàng ngày"
{...register("name")}
disabled={isPending}
/>
{errors.name && (
<p className="text-xs text-red-400">{errors.name.message}</p>
)}
</div>
{/* Target URL */}
<div className="space-y-1.5">
<Label htmlFor="targetUrl">Địa Chỉ URL Đích (Target URL)</Label>
<Input
id="targetUrl"
type="url"
placeholder="https://example.com/news"
{...register("targetUrl")}
disabled={isPending}
/>
{errors.targetUrl && (
<p className="text-xs text-red-400">{errors.targetUrl.message}</p>
)}
</div>
{/* Grid: Max Depth & Max Pages */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="maxDepth">Độ Sâu (Depth Level)</Label>
<Input
id="maxDepth"
type="number"
min={1}
max={10}
{...register("maxDepth", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.maxDepth && (
<p className="text-xs text-red-400">{errors.maxDepth.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="maxPages">Số Trang Tối Đa</Label>
<Input
id="maxPages"
type="number"
min={1}
max={5000}
{...register("maxPages", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.maxPages && (
<p className="text-xs text-red-400">{errors.maxPages.message}</p>
)}
</div>
</div>
{/* Rate Limit */}
<div className="space-y-1.5">
<Label htmlFor="rateLimit">Độ Trễ Giữa Các Request (ms)</Label>
<Input
id="rateLimit"
type="number"
min={100}
step={100}
{...register("rateLimit", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.rateLimit && (
<p className="text-xs text-red-400">{errors.rateLimit.message}</p>
)}
</div>
{/* CSS Selectors */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="extractSelectors">CSS Selectors Cần Bóc Tách</Label>
<span className="text-[11px] text-slate-400">Tùy chọn</span>
</div>
<Input
id="extractSelectors"
placeholder="h1.title, .article-content, span.price"
{...register("extractSelectors")}
disabled={isPending}
/>
</div>
{/* Submit Button */}
<div className="pt-2">
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-500 font-semibold"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang khởi tạo tác vụ...
</>
) : (
<>
<PlusCircle className="mr-2 h-4 w-4" />
Khởi Chạy Tác Vụ Crawl
</>
)}
</Button>
</div>
</form>
);
}
"use client";
import React from "react";
import {
useCrawlerTasks,
useDeleteCrawlerTask,
useToggleCrawlerTask,
} from "@/hooks/use-crawler";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
import { CrawlerStatus } from "@/types/crawler";
import {
ExternalLink,
Loader2,
Pause,
Play,
Trash2,
AlertCircle,
Clock,
Layers,
} from "lucide-react";
export function CrawlerTaskTable() {
const { data, isLoading, isError, error } = useCrawlerTasks();
const { mutate: toggleStatus, isPending: isToggling } = useToggleCrawlerTask();
const { mutate: deleteTask, isPending: isDeleting } = useDeleteCrawlerTask();
const getStatusBadge = (status: CrawlerStatus) => {
switch (status) {
case "RUNNING":
return (
<Badge variant="default" className="gap-1.5 py-1">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
ĐANG CHẠY
</Badge>
);
case "COMPLETED":
return (
<Badge variant="info" className="gap-1.5 py-1">
HOÀN THÀNH
</Badge>
);
case "PAUSED":
return (
<Badge variant="warning" className="gap-1.5 py-1">
TẠM DỪNG
</Badge>
);
case "FAILED":
return (
<Badge variant="destructive" className="gap-1.5 py-1">
THẤT BẠI
</Badge>
);
case "PENDING":
default:
return (
<Badge variant="secondary" className="gap-1.5 py-1">
CHỜ XỬ LÝ
</Badge>
);
}
};
if (isLoading) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-8 text-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
<p className="text-sm text-slate-400">Đang tải danh sách tác vụ crawl...</p>
</div>
);
}
if (isError) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-red-900/30 bg-red-950/20 p-8 text-center text-red-300">
<AlertCircle className="h-8 w-8 text-red-400" />
<p className="text-sm font-semibold">Lỗi tải dữ liệu</p>
<p className="text-xs text-red-400/80">{(error as Error)?.message || "Vui lòng thử lại"}</p>
</div>
);
}
const tasks = data?.items || [];
if (tasks.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-800 bg-slate-900/30 p-8 text-center">
<Layers className="h-10 w-10 text-slate-600" />
<h3 className="font-semibold text-slate-300">Chưa có tác vụ crawl nào</h3>
<p className="text-xs text-slate-500 max-w-sm">
Bấm nút "Tạo Tác Vụ Mới" phía trên để bắt đầu cấu hình và thu thập dữ liệu web.
</p>
</div>
);
}
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl shadow-black/20">
<Table>
<TableHeader>
<TableRow className="border-slate-800 bg-slate-950/40">
<TableHead>Tên Tác Vụ & Target</TableHead>
<TableHead>Trạng Thái</TableHead>
<TableHead>Tiến Độ Cào</TableHead>
<TableHead>Dữ Liệu Bóc Tách</TableHead>
<TableHead>Thời Gian Tạo</TableHead>
<TableHead className="text-right">Hành Động</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => {
const progress = Math.min(
100,
Math.round((task.pagesCrawled / Math.max(1, task.maxPages)) * 100)
);
return (
<TableRow key={task.id} className="border-slate-800/80">
{/* Name & Target */}
<TableCell className="max-w-[280px]">
<div className="font-medium text-slate-100">{task.name}</div>
<a
href={task.targetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-400/80 hover:text-emerald-300 hover:underline truncate max-w-full mt-0.5"
>
<span className="truncate">{task.targetUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</TableCell>
{/* Status */}
<TableCell>{getStatusBadge(task.status)}</TableCell>
{/* Progress */}
<TableCell className="min-w-[160px]">
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>{task.pagesCrawled} / {task.maxPages} trang</span>
<span className="font-semibold text-slate-300">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-cyan-400 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</TableCell>
{/* Extracted Count */}
<TableCell>
<span className="font-semibold text-emerald-400">
{task.itemsExtracted.toLocaleString()}
</span>{" "}
<span className="text-xs text-slate-400">mục</span>
</TableCell>
{/* Created At */}
<TableCell className="text-xs text-slate-400">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-slate-500" />
{formatDate(task.createdAt)}
</div>
</TableCell>
{/* Actions */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
{task.status === "RUNNING" ? (
<Button
size="sm"
variant="secondary"
title="Tạm dừng"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-amber-400 hover:text-amber-300"
>
<Pause className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
variant="secondary"
title="Tiếp tục chạy"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-emerald-400 hover:text-emerald-300"
>
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="destructive"
title="Xóa tác vụ"
disabled={isDeleting}
onClick={() => deleteTask(task.id)}
className="h-8 px-2.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
secondary:
"border-transparent bg-slate-800 text-slate-300",
destructive:
"border-transparent bg-red-500/15 text-red-400 border-red-500/30",
outline: "text-slate-300 border-slate-700",
warning:
"border-amber-500/30 bg-amber-500/15 text-amber-400",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-400",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
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 buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer shadow-sm",
{
variants: {
variant: {
default:
"bg-emerald-600 text-white shadow hover:bg-emerald-700 active:scale-[0.98] transition-all",
destructive:
"bg-red-500 text-white shadow-sm hover:bg-red-600 active:scale-[0.98]",
outline:
"border border-slate-700 bg-slate-900/60 hover:bg-slate-800 hover:text-emerald-400 text-slate-200",
secondary:
"bg-slate-800 text-slate-100 hover:bg-slate-700",
ghost: "hover:bg-slate-800/80 hover:text-slate-100 text-slate-300",
link: "text-emerald-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md text-slate-100 shadow-lg shadow-black/20",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight text-slate-100", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-800 bg-slate-900 p-6 shadow-2xl duration-200 sm:rounded-xl text-slate-100",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none text-slate-400 hover:text-slate-100">
<X className="h-4 w-4" />
<span className="sr-only">Đóng</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-100",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-slate-700 bg-slate-900/80 px-3 py-1 text-sm text-slate-100 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-slate-100 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-xs font-semibold uppercase tracking-wider text-slate-400 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b border-slate-800", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-slate-900/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-slate-800/60 transition-colors hover:bg-slate-800/40 data-[state=selected]:bg-slate-800",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-semibold text-xs text-slate-400 uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle text-slate-200 [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
};
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlerService } from "@/services/crawler.service";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { toast } from "sonner";
export const CRAWLER_QUERY_KEYS = {
tasks: ["crawler", "tasks"] as const,
stats: ["crawler", "stats"] as const,
};
export function useCrawlerTasks() {
return useQuery({
queryKey: CRAWLER_QUERY_KEYS.tasks,
queryFn: () => crawlerService.getTasks(),
refetchInterval: 5000, // Cập nhật tự động mỗi 5 giây
});
}
export function useCrawlerStats() {
return useQuery({
queryKey: CRAWLER_QUERY_KEYS.stats,
queryFn: () => crawlerService.getStats(),
refetchInterval: 10000,
});
}
export function useCreateCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateCrawlerTaskInput) => crawlerService.createTask(input),
onSuccess: (data) => {
toast.success(`Đã khởi tạo tác vụ cào dữ liệu: "${data.name}"`);
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể tạo tác vụ cào dữ liệu");
},
});
}
export function useToggleCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlerService.toggleTaskStatus(id),
onSuccess: (updatedTask) => {
toast.info(`Tác vụ "${updatedTask.name}" chuyển sang: ${updatedTask.status}`);
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể thay đổi trạng thái tác vụ");
},
});
}
export function useDeleteCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlerService.deleteTask(id),
onSuccess: () => {
toast.success("Đã xóa tác vụ thành công");
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể xóa tác vụ");
},
});
}
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
export interface ApiErrorResponse {
message: string;
statusCode?: number;
errors?: Record<string, string[]>;
}
export class ApiError extends Error {
statusCode?: number;
errors?: Record<string, string[]>;
constructor(message: string, statusCode?: number, errors?: Record<string, string[]>) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.errors = errors;
}
}
const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
export const apiClient: AxiosInstance = axios.create({
baseURL,
timeout: 30000,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
// Request Interceptor
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("auth_token");
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiErrorResponse>) => {
const message =
error.response?.data?.message ||
error.message ||
"Đã có lỗi xảy ra khi kết nối tới máy chủ.";
const statusCode = error.response?.status;
const errors = error.response?.data?.errors;
return Promise.reject(new ApiError(message, statusCode, errors));
}
);
export default apiClient;
import { QueryClient, isServer } from "@tanstack/react-query";
function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient(): QueryClient {
if (isServer) {
// Server: always create a new query client
return makeQueryClient();
} else {
// Browser: create once or reuse existing client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date | number): string {
return new Intl.DateTimeFormat("vi-VN", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
"use client";
import React, { ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "@/lib/query-client";
import { Toaster } from "sonner";
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
import { z } from "zod";
export const createCrawlerTaskSchema = z.object({
name: z
.string()
.min(3, "Tên tác vụ phải có ít nhất 3 ký tự")
.max(100, "Tên tác vụ không được vượt quá 100 ký tự"),
targetUrl: z
.string()
.url("Vui lòng nhập đúng định dạng URL (vd: https://example.com)"),
maxDepth: z
.number()
.int()
.min(1, "Độ sâu tối thiểu là 1")
.max(10, "Độ sâu tối đa là 10"),
maxPages: z
.number()
.int()
.min(1, "Tối thiểu phải cào 1 trang")
.max(5000, "Tối đa cào 5000 trang trong một phiên"),
rateLimit: z
.number()
.int()
.min(100, "Độ trễ tối thiểu 100ms")
.max(10000, "Độ trễ tối đa 10000ms"),
extractSelectors: z.string().optional(),
});
export type CreateCrawlerTaskInput = z.infer<typeof createCrawlerTaskSchema>;
import apiClient from "@/lib/api-client";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import { CrawlerStats, CrawlerTask } from "@/types/crawler";
// Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [
{
id: "task-01",
name: "Cào tin tức Công nghệ & AI",
targetUrl: "https://vnexpress.net/so-hoa/cong-nghe",
status: "RUNNING",
maxDepth: 3,
maxPages: 100,
pagesCrawled: 42,
itemsExtracted: 318,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date(Date.now() - 60000).toISOString(),
},
{
id: "task-02",
name: "Trích xuất giá sản phẩm Laptop",
targetUrl: "https://tiki.vn/laptop/c8095",
status: "COMPLETED",
maxDepth: 2,
maxPages: 50,
pagesCrawled: 50,
itemsExtracted: 620,
createdAt: new Date(Date.now() - 3600000 * 24).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "task-03",
name: "Thu thập danh bạ doanh nghiệp",
targetUrl: "https://yellowpages.vn/danh-ba",
status: "PAUSED",
maxDepth: 2,
maxPages: 200,
pagesCrawled: 75,
itemsExtracted: 180,
createdAt: new Date(Date.now() - 3600000 * 48).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 12).toISOString(),
},
];
let localTasksState = [...INITIAL_MOCK_TASKS];
export const crawlerService = {
// Lấy danh sách task (kết nối API, fallback mock)
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlerTask>>>("/crawler/tasks");
return response.data.data;
} catch {
// Fallback local memory state cho demo
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
}
},
// Lấy thống kê tổng quan
async getStats(): Promise<CrawlerStats> {
try {
const response = await apiClient.get<ApiResponse<CrawlerStats>>("/crawler/stats");
return response.data.data;
} catch {
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const active = localTasksState.filter((t) => t.status === "RUNNING").length;
return {
activeTasks: active,
totalTasks: localTasksState.length,
totalPagesCrawled: totalPages,
totalItemsExtracted: totalItems,
successRate: 98.4,
};
}
},
// Tạo mới một task
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlerTask>>("/crawler/tasks", input);
return response.data.data;
} catch {
const newTask: CrawlerTask = {
id: `task-${Date.now()}`,
name: input.name,
targetUrl: input.targetUrl,
status: "RUNNING",
maxDepth: input.maxDepth,
maxPages: input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date().toISOString(),
};
localTasksState = [newTask, ...localTasksState];
return newTask;
}
},
// Dừng / chạy tiếp task
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
const response = await apiClient.patch<ApiResponse<CrawlerTask>>(`/crawler/tasks/${id}/toggle`);
return response.data.data;
} catch {
const task = localTasksState.find((t) => t.id === id);
if (!task) throw new Error("Task not found");
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
}
},
// Xóa task
async deleteTask(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawler/tasks/${id}`);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
}
},
};
export interface ApiResponse<T = unknown> {
success: boolean;
message?: string;
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export type CrawlerStatus =
| "PENDING"
| "RUNNING"
| "COMPLETED"
| "FAILED"
| "PAUSED";
export interface CrawlerTask {
id: string;
name: string;
targetUrl: string;
status: CrawlerStatus;
maxDepth: number;
maxPages: number;
pagesCrawled: number;
itemsExtracted: number;
createdAt: string;
updatedAt: string;
lastRunAt?: string;
errorMessage?: string;
}
export interface CrawlerStats {
activeTasks: number;
totalTasks: number;
totalPagesCrawled: number;
totalItemsExtracted: number;
successRate: number;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment