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*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# 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
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
| Công nghệ | Vai trò |
| :--- | :--- |
| **pnpm** | Package manager hiệu năng cao |
| **Next.js 16** | Framework React với App Router (`src/app`) |
| **TypeScript** | Type-safety toàn diện |
| **Tailwind CSS v4** | Modern utility-first CSS & theme tokens |
| **shadcn/ui** | UI Component system (Radix UI primitives) |
| **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.
## Learn More
```
data-crawler-fe/
├── .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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 🛠️ Hướng dẫn cài đặt & khởi chạy
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"
}
}
......@@ -8,6 +8,39 @@ importers:
.:
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:
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)
......@@ -17,6 +50,18 @@ importers:
react-dom:
specifier: 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:
'@tailwindcss/postcss':
specifier: ^4
......@@ -172,6 +217,99 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
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':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
......@@ -451,9 +589,294 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
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':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
......@@ -549,6 +972,23 @@ packages:
'@tailwindcss/postcss@4.3.3':
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':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
......@@ -761,6 +1201,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
......@@ -771,6 +1215,10 @@ packages:
argparse@2.0.1:
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:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
......@@ -814,6 +1262,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
......@@ -822,6 +1273,9 @@ packages:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
......@@ -877,9 +1331,16 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
......@@ -887,6 +1348,10 @@ packages:
color-name@1.1.4:
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:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
......@@ -943,10 +1408,17 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
......@@ -1174,10 +1646,23 @@ packages:
flatted@3.4.4:
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:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
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:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
......@@ -1200,6 +1685,10 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
engines: {node: '>=6'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
......@@ -1271,6 +1760,10 @@ packages:
hermes-parser@0.25.1:
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:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
......@@ -1548,6 +2041,11 @@ packages:
lru-cache@5.1.1:
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:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
......@@ -1563,6 +2061,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
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:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
......@@ -1711,6 +2217,10 @@ packages:
prop-types@15.8.1:
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:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
......@@ -1723,9 +2233,45 @@ packages:
peerDependencies:
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:
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:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
......@@ -1826,6 +2372,16 @@ packages:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
......@@ -1889,6 +2445,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
......@@ -1967,6 +2526,26 @@ packages:
uri-js@4.4.1:
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:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
......@@ -2184,6 +2763,30 @@ snapshots:
'@eslint/core': 0.17.0
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':
dependencies:
'@humanfs/types': 0.15.0
......@@ -2380,8 +2983,264 @@ snapshots:
'@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': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
......@@ -2455,6 +3314,21 @@ snapshots:
postcss: 8.5.28
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':
dependencies:
tslib: 2.8.1
......@@ -2645,6 +3519,12 @@ snapshots:
acorn@8.18.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv@6.15.0:
dependencies:
fast-deep-equal: 3.1.3
......@@ -2658,6 +3538,10 @@ snapshots:
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
aria-query@5.3.2: {}
array-buffer-byte-length@1.0.2:
......@@ -2731,12 +3615,24 @@ snapshots:
async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.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: {}
balanced-match@1.0.2: {}
......@@ -2792,14 +3688,24 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
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: {}
convert-source-map@2.0.0: {}
......@@ -2854,8 +3760,12 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
delayed-stream@1.0.0: {}
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
......@@ -3237,10 +4147,20 @@ snapshots:
flatted@3.4.4: {}
follow-redirects@1.16.0: {}
for-each@0.3.5:
dependencies:
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.prototype.name@1.2.0:
......@@ -3274,6 +4194,8 @@ snapshots:
hasown: 2.0.4
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
......@@ -3338,6 +4260,13 @@ snapshots:
dependencies:
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@7.0.8: {}
......@@ -3591,6 +4520,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@1.41.0(react@19.2.8):
dependencies:
react: 19.2.8
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
......@@ -3604,6 +4537,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
......@@ -3760,6 +4699,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
proxy-from-env@2.1.0: {}
punycode@2.3.1: {}
queue-microtask@1.2.3: {}
......@@ -3769,8 +4710,39 @@ snapshots:
react: 19.2.8
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-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: {}
reflect.getprototypeof@1.0.10:
......@@ -3927,6 +4899,13 @@ snapshots:
side-channel-map: 1.0.1
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: {}
stable-hash@0.0.5: {}
......@@ -4004,6 +4983,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
......@@ -4126,6 +5107,21 @@ snapshots:
dependencies:
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:
dependencies:
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";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #090d16;
--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 {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--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 {
background: var(--background);
background-color: var(--background);
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 { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/providers/providers";
import { Navbar } from "@/components/common/navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
......@@ -13,17 +15,31 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Data Crawler System | Nền Tảng Thu Thập Dữ Liệu Tự Động",
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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
<html lang="vi" className="dark">
<body
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>
);
}
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 (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<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">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
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.
<div className="space-y-8">
{/* Top Banner / Hero */}
<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">
<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" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-2">
<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">
<Sparkles className="h-3.5 w-3.5" />
Next.js 16 + TanStack Query + shadcn/ui Architecture
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-white">
Data Crawler Management
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
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 className="text-sm text-slate-400 max-w-2xl">
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.
Đượ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.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
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]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
<div className="flex items-center gap-3 shrink-0">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<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">
<Plus className="mr-2 h-4 w-4" />
Tạo Tác Vụ Mới
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<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
</a>
<a
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]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
{/* Main Content: Tasks Table */}
<div className="space-y-4" id="tasks">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<Server className="h-5 w-5 text-emerald-400" />
Danh Sách Tác Vụ Cào Dữ Liệu
</h2>
<p className="text-xs text-slate-400">
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>
);
}
"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