feat: modernize frontend UI with Tailwind v4 and professional design system
BREAKING CHANGE: Migrated to Tailwind CSS v4 configuration system Key Changes: - Migrated from Tailwind v3 to v4 configuration system - Removed tailwind.config.js (incompatible with v4) - Updated index.css with @theme directive and oklch color space - Defined all custom animations directly in CSS using @keyframes - Redesigned LoginPage with modern, enterprise-grade UI: - Full-screen gradient background (blue → purple → pink) - Floating animated orbs with blur effects - Glass morphism white card with backdrop-blur - Gradient buttons with shadow effects - 7 custom animations: fade-in, slide-in-right, slide-in-left, scale-in, shimmer, pulse, float - Added shadcn/ui components: - alert.tsx, dialog.tsx, input.tsx, label.tsx, select.tsx, tabs.tsx - Updated dependencies: - Added class-variance-authority ^0.7.0 - Added react-markdown ^9.0.1 - Updated frontend documentation: - Comprehensive README.md with feature list, tech stack, project structure - Quick start guide and deployment instructions Technical Details: - Tailwind v4 uses @import "tailwindcss" instead of @tailwind directives - All theme customization now in @theme block with CSS variables - Color system migrated to oklch for better perceptual uniformity - Animation definitions moved from config to CSS @layer utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,73 +1,227 @@
|
|||||||
# React + TypeScript + Vite
|
# Tool_OCR Frontend
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
> 現代化的 OCR 文件處理系統前端介面
|
||||||
|
>
|
||||||
|
> 基於 React 18 + Vite + TypeScript + Tailwind CSS + shadcn/ui
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
---
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
## 目錄
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
- [快速開始](#快速開始)
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [技術棧](#技術棧)
|
||||||
|
- [專案結構](#專案結構)
|
||||||
|
- [開發指南](#開發指南)
|
||||||
|
- [部署說明](#部署說明)
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
---
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
## 快速開始
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
### 前置需求
|
||||||
|
|
||||||
```js
|
- Node.js >= 18.0.0
|
||||||
export default defineConfig([
|
- npm >= 9.0.0
|
||||||
globalIgnores(['dist']),
|
- 後端 API 服務運行在 `http://localhost:12010`
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
### 安裝依賴
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
```bash
|
||||||
],
|
cd frontend
|
||||||
languageOptions: {
|
npm install
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
### 啟動開發伺服器
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
// eslint.config.js
|
npm run dev
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
瀏覽器開啟: `http://localhost:12011`
|
||||||
|
|
||||||
|
### 建置生產版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
建置輸出目錄: `dist/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 使用者認證
|
||||||
|
- JWT Token 認證
|
||||||
|
- 自動登出處理
|
||||||
|
- 受保護路由
|
||||||
|
|
||||||
|
### 2. 批次檔案上傳
|
||||||
|
- **拖放上傳介面** (react-dropzone)
|
||||||
|
- **支援格式**: PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX
|
||||||
|
- **批次限制**: 單檔 50MB, 每批次最多 100 檔
|
||||||
|
- **即時預覽**: 檔案清單、大小、狀態
|
||||||
|
|
||||||
|
### 3. OCR 處理追蹤
|
||||||
|
- **即時進度顯示** (2 秒輪詢)
|
||||||
|
- **統計儀表板**: 已完成/處理中/失敗數量
|
||||||
|
- **檔案級別狀態**: 處理狀態、時間、錯誤訊息
|
||||||
|
- **自動跳轉**: 處理完成後自動進入結果頁面
|
||||||
|
|
||||||
|
### 4. 結果預覽
|
||||||
|
- **雙欄佈局**: 檔案清單 + 內容預覽
|
||||||
|
- **多格式預覽**: Markdown 渲染 / JSON 資料
|
||||||
|
- **統計資訊**: OCR 信心度、處理時間、文字區塊數
|
||||||
|
|
||||||
|
### 5. 靈活匯出
|
||||||
|
- **匯出格式**: TXT, JSON, Excel, Markdown, PDF, ZIP
|
||||||
|
- **自訂規則**: 建立、編輯、刪除匯出規則
|
||||||
|
- **CSS 模板**: 選擇 PDF 樣式 (default, academic, business)
|
||||||
|
|
||||||
|
### 6. 設定管理
|
||||||
|
- 使用者資訊顯示
|
||||||
|
- 匯出規則 CRUD 操作
|
||||||
|
- 系統設定
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技術棧
|
||||||
|
|
||||||
|
### 核心框架
|
||||||
|
- **React 18.2** - 使用者介面框架
|
||||||
|
- **TypeScript 5.9** - 型別安全
|
||||||
|
- **Vite 7.2** - 快速建置工具
|
||||||
|
|
||||||
|
### 狀態管理
|
||||||
|
- **React Query (TanStack Query)** - 伺服器狀態管理
|
||||||
|
- **Zustand** - 客戶端狀態管理
|
||||||
|
|
||||||
|
### UI 組件庫
|
||||||
|
- **Tailwind CSS 4.1** - CSS 框架
|
||||||
|
- **shadcn/ui** - React 組件庫
|
||||||
|
- **Lucide React** - 圖示庫
|
||||||
|
|
||||||
|
### 路由與 HTTP
|
||||||
|
- **React Router 7.9** - 客戶端路由
|
||||||
|
- **Axios** - HTTP 請求
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
- **react-dropzone** - 拖放上傳
|
||||||
|
- **react-markdown** - Markdown 渲染
|
||||||
|
- **i18next** - 國際化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui 組件
|
||||||
|
│ │ ├── FileUpload.tsx
|
||||||
|
│ │ ├── ResultsTable.tsx
|
||||||
|
│ │ ├── MarkdownPreview.tsx
|
||||||
|
│ │ └── Layout.tsx
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── LoginPage.tsx
|
||||||
|
│ │ ├── UploadPage.tsx
|
||||||
|
│ │ ├── ProcessingPage.tsx
|
||||||
|
│ │ ├── ResultsPage.tsx
|
||||||
|
│ │ ├── ExportPage.tsx
|
||||||
|
│ │ └── SettingsPage.tsx
|
||||||
|
│ ├── store/
|
||||||
|
│ │ ├── authStore.ts
|
||||||
|
│ │ └── uploadStore.ts
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── api.ts
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ └── utils.ts
|
||||||
|
│ ├── i18n/
|
||||||
|
│ ├── styles/
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── main.tsx
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── tailwind.config.js
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 開發指南
|
||||||
|
|
||||||
|
### 安裝新依賴
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安裝 class-variance-authority (如果尚未安裝)
|
||||||
|
npm install class-variance-authority
|
||||||
|
|
||||||
|
# 安裝 react-markdown (如果尚未安裝)
|
||||||
|
npm install react-markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### 新增 shadcn/ui 組件
|
||||||
|
|
||||||
|
所有 shadcn/ui 組件已在 `src/components/ui/` 目錄中,包括:
|
||||||
|
- Button, Card, Input, Label, Select
|
||||||
|
- Alert, Dialog, Tabs, Badge
|
||||||
|
- Progress, Toast, Table
|
||||||
|
|
||||||
|
### 整合新 API
|
||||||
|
|
||||||
|
1. 在 `src/types/api.ts` 定義型別
|
||||||
|
2. 在 `src/services/api.ts` 新增方法
|
||||||
|
3. 在頁面組件使用 React Query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署說明
|
||||||
|
|
||||||
|
### 環境變數
|
||||||
|
|
||||||
|
建立 `.env.production`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_BASE_URL=http://your-api-server.com:12010
|
||||||
|
VITE_APP_NAME=Tool_OCR
|
||||||
|
```
|
||||||
|
|
||||||
|
### 建置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 部署到 Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name tool-ocr.example.com;
|
||||||
|
root /var/www/tool-ocr;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://127.0.0.1:12010;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 參考文件
|
||||||
|
|
||||||
|
- [FRONTEND_API.md](../FRONTEND_API.md) - 完整前端 API 文件
|
||||||
|
- [API_REFERENCE.md](../API_REFERENCE.md) - 後端 API 參考
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**版本**: 0.1.0 | **最後更新**: 2025-01-13
|
||||||
|
|||||||
1208
frontend/package-lock.json
generated
1208
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"i18next": "^25.6.2",
|
"i18next": "^25.6.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-i18next": "^16.3.0",
|
"react-i18next": "^16.3.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^7.9.5",
|
"react-router-dom": "^7.9.5",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
|
|||||||
76
frontend/src/components/ui/alert.tsx
Normal file
76
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { AlertCircle, CheckCircle2, Info, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:pl-7',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground border-border',
|
||||||
|
info: 'bg-primary/10 text-primary border-primary/30',
|
||||||
|
success: 'bg-success/10 text-success border-success/30',
|
||||||
|
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||||
|
destructive: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
default: Info,
|
||||||
|
info: Info,
|
||||||
|
success: CheckCircle2,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
destructive: AlertCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof alertVariants> {
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||||
|
({ className, variant = 'default', showIcon = true, children, ...props }, ref) => {
|
||||||
|
const Icon = icons[variant || 'default']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && <Icon className="h-5 w-5" />}
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Alert.displayName = 'Alert'
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AlertTitle.displayName = 'AlertTitle'
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = 'AlertDescription'
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
118
frontend/src/components/ui/dialog.tsx
Normal file
118
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dialog: React.FC<DialogProps> = ({ open, onClose, children }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
document.body.style.overflow = 'unset'
|
||||||
|
}
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative z-50 w-full max-w-lg mx-4 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'bg-card rounded-xl border border-border shadow-xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DialogContent.displayName = 'DialogContent'
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center justify-between p-6 border-b border-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h2
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
DialogTitle.displayName = 'DialogTitle'
|
||||||
|
|
||||||
|
const DialogBody = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('p-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
DialogBody.displayName = 'DialogBody'
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center justify-end gap-3 p-6 border-t border-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = 'DialogFooter'
|
||||||
|
|
||||||
|
const DialogClose = ({
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
className?: string
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter, DialogClose }
|
||||||
28
frontend/src/components/ui/input.tsx
Normal file
28
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||||
|
'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'transition-colors',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
23
frontend/src/components/ui/label.tsx
Normal file
23
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium leading-none text-foreground',
|
||||||
|
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Label.displayName = 'Label'
|
||||||
|
|
||||||
|
export { Label }
|
||||||
38
frontend/src/components/ui/select.tsx
Normal file
38
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ className, options, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||||
|
'ring-offset-background',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'transition-colors appearance-none pr-10',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Select.displayName = 'Select'
|
||||||
|
|
||||||
|
export { Select }
|
||||||
108
frontend/src/components/ui/tabs.tsx
Normal file
108
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface TabsProps {
|
||||||
|
defaultValue?: string
|
||||||
|
value?: string
|
||||||
|
onValueChange?: (value: string) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsContextValue {
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
const Tabs: React.FC<TabsProps> = ({
|
||||||
|
defaultValue,
|
||||||
|
value: controlledValue,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [internalValue, setInternalValue] = React.useState(defaultValue || '')
|
||||||
|
const value = controlledValue !== undefined ? controlledValue : internalValue
|
||||||
|
const handleValueChange = onValueChange || setInternalValue
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||||
|
<div className={className}>{children}</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TabsList.displayName = 'TabsList'
|
||||||
|
|
||||||
|
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||||
|
({ className, value, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext)
|
||||||
|
if (!context) throw new Error('TabsTrigger must be used within Tabs')
|
||||||
|
|
||||||
|
const isSelected = context.value === value
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => context.onValueChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
|
||||||
|
'ring-offset-background transition-all',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
isSelected
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TabsTrigger.displayName = 'TabsTrigger'
|
||||||
|
|
||||||
|
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||||
|
({ className, value, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext)
|
||||||
|
if (!context) throw new Error('TabsContent must be used within Tabs')
|
||||||
|
|
||||||
|
if (context.value !== value) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
TabsContent.displayName = 'TabsContent'
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -1,115 +1,172 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
@theme {
|
||||||
:root {
|
|
||||||
/* Clean, modern color palette */
|
/* Clean, modern color palette */
|
||||||
--background: 220 15% 97%;
|
--color-background: oklch(95% 0.02 220);
|
||||||
--foreground: 220 15% 15%;
|
--color-foreground: oklch(20% 0.02 220);
|
||||||
--card: 0 0% 100%;
|
--color-card: oklch(100% 0 0);
|
||||||
--card-foreground: 220 15% 15%;
|
--color-card-foreground: oklch(20% 0.02 220);
|
||||||
--popover: 0 0% 100%;
|
--color-popover: oklch(100% 0 0);
|
||||||
--popover-foreground: 220 15% 15%;
|
--color-popover-foreground: oklch(20% 0.02 220);
|
||||||
|
|
||||||
/* Primary: Professional blue */
|
/* Primary: Professional blue */
|
||||||
--primary: 217 91% 60%;
|
--color-primary: oklch(65% 0.25 250);
|
||||||
--primary-foreground: 0 0% 100%;
|
--color-primary-foreground: oklch(100% 0 0);
|
||||||
--primary-hover: 217 91% 50%;
|
--color-primary-hover: oklch(60% 0.25 250);
|
||||||
|
|
||||||
/* Secondary: Subtle gray-blue */
|
/* Secondary: Subtle gray-blue */
|
||||||
--secondary: 220 15% 95%;
|
--color-secondary: oklch(95% 0.02 220);
|
||||||
--secondary-foreground: 220 15% 25%;
|
--color-secondary-foreground: oklch(30% 0.02 220);
|
||||||
|
|
||||||
/* Accent: Vibrant teal */
|
/* Accent: Vibrant teal */
|
||||||
--accent: 173 80% 50%;
|
--color-accent: oklch(65% 0.20 180);
|
||||||
--accent-foreground: 0 0% 100%;
|
--color-accent-foreground: oklch(100% 0 0);
|
||||||
|
|
||||||
/* Sidebar */
|
|
||||||
--sidebar: 220 25% 12%;
|
|
||||||
--sidebar-foreground: 220 10% 90%;
|
|
||||||
--sidebar-active: 217 91% 60%;
|
|
||||||
|
|
||||||
/* Muted */
|
/* Muted */
|
||||||
--muted: 220 15% 93%;
|
--color-muted: oklch(93% 0.02 220);
|
||||||
--muted-foreground: 220 10% 45%;
|
--color-muted-foreground: oklch(50% 0.02 220);
|
||||||
|
|
||||||
/* Destructive */
|
/* Destructive */
|
||||||
--destructive: 0 85% 60%;
|
--color-destructive: oklch(60% 0.22 25);
|
||||||
--destructive-foreground: 0 0% 100%;
|
--color-destructive-foreground: oklch(100% 0 0);
|
||||||
|
|
||||||
/* Success */
|
/* Success */
|
||||||
--success: 142 72% 45%;
|
--color-success: oklch(55% 0.20 150);
|
||||||
--success-foreground: 0 0% 100%;
|
--color-success-foreground: oklch(100% 0 0);
|
||||||
|
|
||||||
/* Warning */
|
/* Warning */
|
||||||
--warning: 38 92% 50%;
|
--color-warning: oklch(65% 0.22 60);
|
||||||
--warning-foreground: 0 0% 100%;
|
--color-warning-foreground: oklch(100% 0 0);
|
||||||
|
|
||||||
/* Borders and inputs */
|
/* Borders and inputs */
|
||||||
--border: 220 13% 88%;
|
--color-border: oklch(88% 0.02 220);
|
||||||
--input: 220 13% 88%;
|
--color-input: oklch(88% 0.02 220);
|
||||||
--ring: 217 91% 60%;
|
--color-ring: oklch(65% 0.25 250);
|
||||||
--radius: 0.5rem;
|
|
||||||
|
|
||||||
/* Shadows */
|
/* Border radius */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
--radius-lg: 0.5rem;
|
||||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
--radius-md: calc(0.5rem - 2px);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
--radius-sm: calc(0.5rem - 4px);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
/* Animations */
|
||||||
/* Dark mode with rich colors */
|
--animate-fade-in: fade-in 0.5s ease-out;
|
||||||
--background: 240 20% 8%;
|
--animate-slide-in-right: slide-in-right 0.5s ease-out;
|
||||||
--foreground: 240 5% 95%;
|
--animate-slide-in-left: slide-in-left 0.5s ease-out;
|
||||||
--card: 240 15% 12%;
|
--animate-scale-in: scale-in 0.3s ease-out;
|
||||||
--card-foreground: 240 5% 95%;
|
--animate-shimmer: shimmer 2s linear infinite;
|
||||||
--popover: 240 15% 12%;
|
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
--popover-foreground: 240 5% 95%;
|
--animate-float: float 3s ease-in-out infinite;
|
||||||
|
|
||||||
/* Primary: Brighter in dark mode */
|
|
||||||
--primary: 250 85% 65%;
|
|
||||||
--primary-foreground: 240 20% 8%;
|
|
||||||
--primary-glow: 250 85% 65%;
|
|
||||||
|
|
||||||
/* Secondary */
|
|
||||||
--secondary: 240 15% 18%;
|
|
||||||
--secondary-foreground: 240 5% 95%;
|
|
||||||
|
|
||||||
/* Accent */
|
|
||||||
--accent: 190 85% 55%;
|
|
||||||
--accent-foreground: 240 20% 8%;
|
|
||||||
|
|
||||||
/* Muted */
|
|
||||||
--muted: 240 15% 15%;
|
|
||||||
--muted-foreground: 240 5% 65%;
|
|
||||||
|
|
||||||
/* Destructive */
|
|
||||||
--destructive: 0 80% 60%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
|
|
||||||
/* Success */
|
|
||||||
--success: 142 70% 50%;
|
|
||||||
--success-foreground: 0 0% 100%;
|
|
||||||
|
|
||||||
/* Borders */
|
|
||||||
--border: 240 15% 20%;
|
|
||||||
--input: 240 15% 20%;
|
|
||||||
--ring: 250 85% 65%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
background-color: hsl(var(--background));
|
background-color: var(--color-background);
|
||||||
color: hsl(var(--foreground));
|
color: var(--color-foreground);
|
||||||
font-feature-settings: "rlig" 1, "calt" 1;
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fade-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -1000px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 1000px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-right {
|
||||||
|
animation: slide-in-right 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in-left {
|
||||||
|
animation: slide-in-left 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scale-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shimmer {
|
||||||
|
animation: shimmer 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
@@ -117,17 +174,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
background: hsl(var(--muted));
|
background: var(--color-muted);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background: hsl(var(--muted-foreground) / 0.3);
|
background: color-mix(in oklch, var(--color-muted-foreground) 30%, transparent);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
background: color-mix(in oklch, var(--color-muted-foreground) 50%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
@@ -149,31 +206,31 @@
|
|||||||
.page-title {
|
.page-title {
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: hsl(var(--foreground));
|
color: var(--color-foreground);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
.page-description {
|
||||||
color: hsl(var(--muted-foreground));
|
color: var(--color-muted-foreground);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section */
|
/* Section */
|
||||||
.section {
|
.section {
|
||||||
background: hsl(var(--card));
|
background: var(--color-card);
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--foreground));
|
color: var(--color-foreground);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status badge */
|
/* Status badge */
|
||||||
@@ -187,22 +244,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-badge-success {
|
.status-badge-success {
|
||||||
background: hsl(var(--success) / 0.1);
|
background: color-mix(in oklch, var(--color-success) 10%, transparent);
|
||||||
color: hsl(var(--success));
|
color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge-warning {
|
.status-badge-warning {
|
||||||
background: hsl(var(--warning) / 0.1);
|
background: color-mix(in oklch, var(--color-warning) 10%, transparent);
|
||||||
color: hsl(var(--warning));
|
color: var(--color-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge-error {
|
.status-badge-error {
|
||||||
background: hsl(var(--destructive) / 0.1);
|
background: color-mix(in oklch, var(--color-destructive) 10%, transparent);
|
||||||
color: hsl(var(--destructive));
|
color: var(--color-destructive);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge-info {
|
.status-badge-info {
|
||||||
background: hsl(var(--primary) / 0.1);
|
background: color-mix(in oklch, var(--color-primary) 10%, transparent);
|
||||||
color: hsl(var(--primary));
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { apiClient } from '@/services/api'
|
import { apiClient } from '@/services/api'
|
||||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2 } from 'lucide-react'
|
import { Lock, User, LayoutDashboard, AlertCircle, Loader2, Sparkles, Zap, Shield } from 'lucide-react'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -21,13 +21,11 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.login({ username, password })
|
await apiClient.login({ username, password })
|
||||||
// For now, just set a basic user object (backend doesn't return user info)
|
|
||||||
setUser({ id: 1, username })
|
setUser({ id: 1, username })
|
||||||
navigate('/upload')
|
navigate('/upload')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorDetail = err.response?.data?.detail
|
const errorDetail = err.response?.data?.detail
|
||||||
if (Array.isArray(errorDetail)) {
|
if (Array.isArray(errorDetail)) {
|
||||||
// Handle validation error array from backend
|
|
||||||
setError(errorDetail.map((e: any) => e.msg || e.message || String(e)).join(', '))
|
setError(errorDetail.map((e: any) => e.msg || e.message || String(e)).join(', '))
|
||||||
} else if (typeof errorDetail === 'string') {
|
} else if (typeof errorDetail === 'string') {
|
||||||
setError(errorDetail)
|
setError(errorDetail)
|
||||||
@@ -40,201 +38,198 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="min-h-screen relative overflow-hidden">
|
||||||
{/* Left side - Branding (hidden on mobile) */}
|
{/* Full-screen Animated Background */}
|
||||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary via-primary/90 to-accent relative overflow-hidden">
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-500">
|
||||||
{/* Subtle background pattern */}
|
{/* Animated overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/50 via-transparent to-purple-500/50 animate-pulse"></div>
|
||||||
|
|
||||||
|
{/* Floating orbs */}
|
||||||
|
<div className="absolute top-20 left-20 w-72 h-72 bg-white/10 rounded-full blur-3xl animate-float"></div>
|
||||||
|
<div className="absolute bottom-20 right-20 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-float" style={{ animationDelay: '1s' }}></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-purple-400/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '0.5s' }}></div>
|
||||||
|
|
||||||
|
{/* Grid pattern */}
|
||||||
<div className="absolute inset-0 opacity-10">
|
<div className="absolute inset-0 opacity-10">
|
||||||
<div className="absolute top-0 left-0 w-full h-full"
|
<div className="absolute inset-0" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)', backgroundSize: '50px 50px' }}></div>
|
||||||
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content Container */}
|
||||||
<div className="relative z-10 flex flex-col justify-center px-16 py-12 text-white">
|
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||||
{/* Logo & Title */}
|
<div className="w-full max-w-6xl flex flex-col lg:flex-row gap-8 items-center">
|
||||||
<div className="mb-12">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
{/* Left Side - Branding */}
|
||||||
<div className="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center">
|
<div className="flex-1 text-white text-center lg:text-left animate-fade-in">
|
||||||
<LayoutDashboard className="w-8 h-8" />
|
{/* Logo */}
|
||||||
|
<div className="flex items-center justify-center lg:justify-start gap-4 mb-8">
|
||||||
|
<div className="w-16 h-16 bg-white/20 backdrop-blur-md rounded-2xl flex items-center justify-center shadow-2xl animate-float">
|
||||||
|
<LayoutDashboard className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{t('app.title')}</h1>
|
<h1 className="text-4xl font-bold tracking-tight">Tool_OCR</h1>
|
||||||
<p className="text-white/80 text-sm mt-1">{t('app.subtitle')}</p>
|
<p className="text-white/80 text-sm mt-1">智能 OCR 處理平台</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature highlights */}
|
{/* Features - Only show on larger screens */}
|
||||||
<div className="space-y-6 mb-12">
|
<div className="hidden lg:block space-y-6 mb-12 animate-slide-in-left" style={{ animationDelay: '0.1s' }}>
|
||||||
<h2 className="text-2xl font-semibold mb-6">智能文件識別系統</h2>
|
<h2 className="text-3xl font-bold mb-8">為什麼選擇我們?</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Sparkles className="w-6 h-6" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-lg">高精度識別</h3>
|
<h3 className="font-bold text-xl mb-1">高精度識別</h3>
|
||||||
<p className="text-white/70 text-sm">支援 10+ 種文件格式,識別準確率達 99%</p>
|
<p className="text-white/70">AI 驅動的 OCR 引擎,識別準確率高達 99%</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Zap className="w-6 h-6" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-lg">快速處理</h3>
|
<h3 className="font-bold text-xl mb-1">閃電般快速</h3>
|
||||||
<p className="text-white/70 text-sm">批量處理大量文件,節省時間成本</p>
|
<p className="text-white/70">批量處理數百份文件,大幅提升工作效率</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Shield className="w-6 h-6" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-lg">安全可靠</h3>
|
<h3 className="font-bold text-xl mb-1">安全可靠</h3>
|
||||||
<p className="text-white/70 text-sm">企業級安全保障,數據加密傳輸</p>
|
<p className="text-white/70">企業級加密,確保您的資料絕對安全</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-6">
|
<div className="hidden lg:grid grid-cols-3 gap-4 animate-slide-in-left" style={{ animationDelay: '0.2s' }}>
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||||
<div className="text-3xl font-bold mb-1">99%</div>
|
<div className="text-4xl font-bold mb-2">99%</div>
|
||||||
<div className="text-white/70 text-sm">識別準確率</div>
|
<div className="text-white/70 text-sm">準確率</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||||
<div className="text-3xl font-bold mb-1">10+</div>
|
<div className="text-4xl font-bold mb-2">10+</div>
|
||||||
<div className="text-white/70 text-sm">支援格式</div>
|
<div className="text-white/70 text-sm">支援格式</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||||
<div className="text-3xl font-bold mb-1">1M+</div>
|
<div className="text-4xl font-bold mb-2">24/7</div>
|
||||||
<div className="text-white/70 text-sm">處理文件</div>
|
<div className="text-white/70 text-sm">全天候</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Login form */}
|
{/* Right Side - Login Form */}
|
||||||
<div className="flex-1 flex items-center justify-center p-8 bg-background">
|
<div className="w-full lg:w-auto lg:min-w-[480px] animate-scale-in">
|
||||||
<div className="w-full max-w-md">
|
<div className="bg-white/95 backdrop-blur-xl rounded-3xl p-8 md:p-10 shadow-2xl border border-white/20">
|
||||||
{/* Mobile logo (shown only on small screens) */}
|
<div className="mb-8">
|
||||||
<div className="lg:hidden text-center mb-8">
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">歡迎回來</h2>
|
||||||
<div className="inline-flex items-center gap-3 mb-2">
|
<p className="text-gray-600">登入以開始使用 OCR 服務</p>
|
||||||
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center">
|
|
||||||
<LayoutDashboard className="w-6 h-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form card */}
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="bg-card rounded-xl border border-border p-8 shadow-lg">
|
{/* Username */}
|
||||||
<div className="mb-6">
|
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.1s' }}>
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">{t('auth.loginButton')}</h2>
|
<label htmlFor="username" className="block text-sm font-semibold text-gray-700">
|
||||||
<p className="text-sm text-muted-foreground">請輸入您的帳號資訊以繼續</p>
|
使用者名稱
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
|
||||||
{/* Username field */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
|
||||||
{t('auth.username')}
|
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
<User className="h-5 w-5 text-muted-foreground" />
|
<User className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||||
text-foreground placeholder-muted-foreground
|
text-gray-900 placeholder-gray-400
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||||
transition-colors"
|
hover:border-gray-300
|
||||||
placeholder="輸入用戶名"
|
transition-all duration-200"
|
||||||
|
placeholder="輸入您的用戶名"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password field */}
|
{/* Password */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.2s' }}>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
<label htmlFor="password" className="block text-sm font-semibold text-gray-700">
|
||||||
{t('auth.password')}
|
密碼
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative group">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
<Lock className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||||
text-foreground placeholder-muted-foreground
|
text-gray-900 placeholder-gray-400
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||||
transition-colors"
|
hover:border-gray-300
|
||||||
placeholder="輸入密碼"
|
transition-all duration-200"
|
||||||
|
placeholder="輸入您的密碼"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
<div className="flex items-start gap-3 p-4 bg-red-50 border-2 border-red-200 rounded-xl animate-scale-in">
|
||||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
<p className="text-sm text-red-600 font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit Button */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 px-6 bg-primary text-white rounded-lg font-semibold
|
className="w-full py-4 px-6 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-bold text-lg
|
||||||
shadow-md hover:bg-primary-hover hover:shadow-lg
|
shadow-lg shadow-blue-500/50 hover:shadow-xl hover:shadow-blue-500/60 hover:scale-[1.02]
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
|
focus:outline-none focus:ring-4 focus:ring-blue-500/50
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
flex items-center justify-center gap-2"
|
relative overflow-hidden group
|
||||||
|
animate-slide-in-right"
|
||||||
|
style={{ animationDelay: '0.3s' }}
|
||||||
>
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-blue-700 to-purple-700 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
<span className="relative flex items-center justify-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
<span>{t('common.loading')}</span>
|
<span>登入中...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>{t('auth.loginButton')}</span>
|
<span>立即登入</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-8 text-center animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-gray-500">
|
||||||
Powered by <span className="font-semibold text-foreground">AI Technology</span>
|
Powered by <span className="font-bold text-gray-700">AI Technology</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: "hsl(var(--border))",
|
|
||||||
input: "hsl(var(--input))",
|
|
||||||
ring: "hsl(var(--ring))",
|
|
||||||
background: "hsl(var(--background))",
|
|
||||||
foreground: "hsl(var(--foreground))",
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "hsl(var(--primary))",
|
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: "hsl(var(--muted))",
|
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: "hsl(var(--accent))",
|
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: "hsl(var(--popover))",
|
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: "hsl(var(--card))",
|
|
||||||
foreground: "hsl(var(--card-foreground))",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user