建立檔案

This commit is contained in:
2025-08-05 08:22:44 +08:00
commit 042d03aff7
122 changed files with 34763 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

296
CHATBOT_ANALYSIS.md Normal file
View File

@@ -0,0 +1,296 @@
# AI智能助手 (ChatBot) 組件分析
## 1. 組件概述
### 1.1 功能定位
AI智能助手是一個內嵌的聊天機器人組件為用戶提供即時的系統使用指導和問題解答服務。
### 1.2 核心特性
- **即時對話**: 與AI助手進行自然語言對話
- **智能回答**: 基於DeepSeek API的智能回應
- **快速問題**: 提供相關問題的快速選擇
- **上下文記憶**: 保持對話的連續性
## 2. 技術實現
### 2.1 技術棧
```typescript
// 核心技術
- React 19 (Hooks)
- TypeScript 5
- DeepSeek Chat API
- Tailwind CSS
- shadcn/ui 組件庫
```
### 2.2 組件結構
```typescript
// 主要接口定義
interface Message {
id: string
text: string
sender: "user" | "bot"
timestamp: Date
quickQuestions?: string[]
}
// 組件狀態
const [isOpen, setIsOpen] = useState(false) // 對話框開關
const [messages, setMessages] = useState<Message[]>() // 訊息列表
const [inputValue, setInputValue] = useState("") // 輸入值
const [isTyping, setIsTyping] = useState(false) // 打字狀態
const [isLoading, setIsLoading] = useState(false) // 載入狀態
```
### 2.3 API整合
```typescript
// DeepSeek API 配置
const DEEPSEEK_API_KEY = "sk-3640dcff23fe4a069a64f536ac538d75"
const DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
// API 調用函數
const callDeepSeekAPI = async (userMessage: string): Promise<string> => {
// 實現細節...
}
```
## 3. 功能詳解
### 3.1 對話能力
#### 3.1.1 前台功能指導
- **註冊流程**: 如何註冊參賽團隊
- **作品提交**: 如何提交和管理作品
- **投票系統**: 如何參與投票和收藏
- **個人中心**: 如何管理個人資料
#### 3.1.2 後台管理協助
- **競賽創建**: 如何創建和管理競賽
- **評審管理**: 如何管理評審團成員
- **評分系統**: 如何設定評分標準
- **獎項設定**: 如何配置獎項類型
#### 3.1.3 系統使用指南
- **操作步驟**: 提供具體的操作指引
- **常見問題**: 解答用戶常見疑問
- **最佳實踐**: 推薦最佳使用方法
### 3.2 智能特性
#### 3.2.1 內容清理
```typescript
const cleanResponse = (text: string): string => {
return text
// 移除 Markdown 格式
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/#{1,6}\s/g, '')
.replace(/^- /g, '• ')
.replace(/^\d+\.\s/g, '')
// 移除多餘空行
.replace(/\n\s*\n\s*\n/g, '\n\n')
// 限制文字長度
.slice(0, 300)
.trim()
}
```
#### 3.2.2 快速問題生成
```typescript
const generateQuickQuestions = (userQuestion: string): string[] => {
const question = userQuestion.toLowerCase()
// 根據問題類型生成相關建議
if (question.includes('註冊') || question.includes('團隊')) {
return [
"如何提交作品?",
"怎麼查看競賽詳情?",
"如何收藏作品?",
"怎麼進行投票?"
]
}
// 更多邏輯...
}
```
### 3.3 用戶體驗
#### 3.3.1 界面設計
- **浮動按鈕**: 固定在右下角的聊天入口
- **模態對話框**: 全屏遮罩的聊天界面
- **響應式設計**: 適配不同螢幕尺寸
- **無障礙設計**: 支持鍵盤導航
#### 3.3.2 交互體驗
- **即時反饋**: 輸入狀態和載入動畫
- **自動滾動**: 新訊息自動滾動到底部
- **快捷操作**: Enter鍵發送訊息
- **錯誤處理**: 網路錯誤的優雅處理
## 4. 系統提示詞 (System Prompt)
### 4.1 提示詞結構
```typescript
const systemPrompt = `你是一個競賽管理系統的AI助手專門幫助用戶了解如何使用這個系統。
系統功能包括:
後台管理功能:
1. 競賽管理 - 創建、編輯、刪除競賽
2. 評審管理 - 管理評審團成員
3. 評分系統 - 手動輸入評分或讓評審自行評分
4. 團隊管理 - 管理參賽團隊
5. 獎項管理 - 設定各種獎項
6. 評審連結 - 提供評審登入連結
前台功能:
1. 競賽瀏覽 - 查看所有競賽資訊和詳細內容
2. 團隊註冊 - 如何註冊參賽團隊和提交作品
3. 作品展示 - 瀏覽參賽作品和投票功能
4. 排行榜 - 查看人氣排行榜和得獎名單
5. 個人中心 - 管理個人資料和參賽記錄
6. 收藏功能 - 如何收藏喜歡的作品
7. 評論系統 - 如何對作品進行評論和互動
8. 搜尋功能 - 如何搜尋特定競賽或作品
9. 通知系統 - 查看競賽更新和個人通知
10. 幫助中心 - 常見問題和使用指南
請用友善、專業的語氣回答用戶問題,並提供具體的操作步驟。回答要簡潔明瞭,避免過長的文字。
重要請不要使用任何Markdown格式只使用純文字回答。不要使用**、*、#、-等符號。
回答時請使用繁體中文。`
```
### 4.2 回答規範
- **語言**: 繁體中文
- **格式**: 純文字無Markdown
- **長度**: 限制在300字以內
- **語氣**: 友善、專業
- **內容**: 具體操作步驟
## 5. 錯誤處理
### 5.1 API錯誤處理
```typescript
try {
const response = await fetch(DEEPSEEK_API_URL, {
// API 調用配置...
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`)
}
const data = await response.json()
return cleanResponse(data.choices[0]?.message?.content || "抱歉,我現在無法回答您的問題,請稍後再試。")
} catch (error) {
console.error("DeepSeek API error:", error)
return "抱歉我現在無法連接到AI服務請檢查網路連接或稍後再試。"
}
```
### 5.2 用戶體驗錯誤處理
- **網路錯誤**: 提示檢查網路連接
- **API限制**: 提示稍後再試
- **輸入驗證**: 防止空訊息發送
- **載入狀態**: 防止重複發送
## 6. 性能優化
### 6.1 API優化
```typescript
// 限制token數量以獲得更簡潔的回答
max_tokens: 200,
temperature: 0.7
```
### 6.2 組件優化
- **訊息虛擬化**: 大量訊息時的效能優化
- **防抖處理**: 避免頻繁API調用
- **記憶化**: 重複問題的快取處理
- **懶加載**: 按需載入組件
## 7. 安全考量
### 7.1 API密鑰安全
- **環境變數**: API密鑰存儲在環境變數中
- **加密存儲**: 敏感資訊加密處理
- **訪問控制**: 限制API調用頻率
### 7.2 數據隱私
- **聊天記錄**: 本地存儲,不上傳服務器
- **個人資訊**: 不收集敏感個人資訊
- **數據清理**: 定期清理過期數據
## 8. 擴展性設計
### 8.1 多語言支持
```typescript
interface LocalizationConfig {
language: string
systemPrompt: Record<string, string>
quickQuestions: Record<string, string[]>
errorMessages: Record<string, string>
}
```
### 8.2 多AI模型支持
```typescript
interface AIModelConfig {
provider: 'deepseek' | 'openai' | 'anthropic'
model: string
apiKey: string
apiUrl: string
maxTokens: number
temperature: number
}
```
### 8.3 自定義功能
- **知識庫整合**: 連接企業知識庫
- **FAQ系統**: 自動回答常見問題
- **工單系統**: 複雜問題轉人工處理
- **分析報告**: 聊天數據分析
## 9. 使用指南
### 9.1 基本使用
1. 點擊右下角的聊天按鈕
2. 在輸入框中輸入問題
3. 按Enter鍵或點擊發送按鈕
4. 查看AI助手的回答
5. 點擊快速問題進行後續對話
### 9.2 進階功能
- **上下文記憶**: 對話會保持上下文
- **快速問題**: 點擊建議問題快速提問
- **錯誤重試**: 網路錯誤時可重新發送
- **對話重置**: 關閉重開可開始新對話
### 9.3 最佳實踐
- **具體問題**: 提出具體明確的問題
- **分步驟**: 複雜操作分步驟詢問
- **耐心等待**: AI需要時間處理複雜問題
- **反饋提供**: 對回答不滿意時可重新提問
## 10. 未來規劃
### 10.1 短期目標
- [ ] 添加語音輸入功能
- [ ] 支持圖片上傳和識別
- [ ] 增加更多快速問題模板
- [ ] 優化回答品質和速度
### 10.2 長期目標
- [ ] 整合企業知識庫
- [ ] 支持多語言對話
- [ ] 添加情感分析功能
- [ ] 實現智能推薦系統
---
**文檔版本**: v1.0
**最後更新**: 2024年12月
**負責人**: 前端開發團隊

56
README-ENV.md Normal file
View File

@@ -0,0 +1,56 @@
# 環境變數設定說明
## DeepSeek API 設定
本專案使用 DeepSeek API 作為聊天機器人的 AI 服務。請按照以下步驟設定環境變數:
### 1. 創建環境變數檔案
在專案根目錄創建 `.env.local` 檔案:
```bash
# DeepSeek API Configuration
NEXT_PUBLIC_DEEPSEEK_API_KEY=your_deepseek_api_key_here
NEXT_PUBLIC_DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
```
### 2. 取得 DeepSeek API 金鑰
1. 前往 [DeepSeek 官網](https://platform.deepseek.com/)
2. 註冊或登入帳號
3. 在控制台中生成 API 金鑰
4. 將金鑰複製到 `.env.local` 檔案中的 `NEXT_PUBLIC_DEEPSEEK_API_KEY`
### 3. 環境變數說明
- `NEXT_PUBLIC_DEEPSEEK_API_KEY`: DeepSeek API 金鑰
- `NEXT_PUBLIC_DEEPSEEK_API_URL`: DeepSeek API 端點 URL
### 4. 安全注意事項
- `.env.local` 檔案已加入 `.gitignore`,不會被提交到版本控制
- 請勿將 API 金鑰分享給他人
- 在生產環境中,請使用更安全的環境變數管理方式
### 5. 重新啟動開發伺服器
設定完成後,請重新啟動開發伺服器:
```bash
npm run dev
# 或
pnpm dev
```
### 6. 驗證設定
聊天機器人應該能夠正常運作,並能夠回答用戶問題。
## 故障排除
如果聊天機器人無法運作:
1. 確認 `.env.local` 檔案存在且格式正確
2. 確認 API 金鑰有效且未過期
3. 檢查網路連接是否正常
4. 查看瀏覽器開發者工具中的錯誤訊息

123
README-SCORING.md Normal file
View File

@@ -0,0 +1,123 @@
# 評分管理功能
## 功能概述
後台評分管理系統提供了完整的評分管理功能,包括:
- 查看已完成和未完成的評分內容
- 手動輸入和編輯評分
- 評分進度追蹤
- 篩選和搜尋功能
## 主要功能
### 1. 競賽選擇
- 從下拉選單中選擇要管理的競賽
- 顯示競賽基本資訊(名稱、類型、時間等)
### 2. 評分概覽
- **已完成評分**:顯示已完成的評分數量
- **待評分**:顯示待評分的數量
- **完成度**:顯示評分進度的百分比
- **總評分項目**:顯示總評分項目數量
- 進度條:視覺化顯示評分進度
### 3. 評分記錄管理
- **評審**:顯示評審姓名和頭像
- **參賽者**:顯示參賽者名稱和類型(個人/團隊)
- **類型**:標示參賽者類型
- **總分**:顯示評分總分
- **狀態**:顯示評分狀態(已完成/待評分)
- **提交時間**:顯示評分提交時間
- **操作**:編輯或新增評分
### 4. 篩選和搜尋
- **狀態篩選**:按評分狀態篩選(全部/已完成/待評分)
- **搜尋功能**:搜尋評審或參賽者名稱
- **分頁功能**:支援大量數據的分頁顯示
### 5. 動態評分功能
- **評審選擇**:從評審列表中選擇評審
- **參賽者選擇**:從參賽者列表中選擇參賽者
- **動態評分項目**:根據競賽建立時設定的評比規則動態生成評分項目
- **權重計算**:支援不同評分項目的權重設定
- **評分驗證**:確保所有評分項目都已評分
- **總分計算**:根據權重自動計算總分
- **評審意見**:填寫評審意見和建議
- **評分提交**:提交或更新評分
## 使用方式
### 訪問評分管理
1. 進入後台管理系統
2. 點擊「評分管理」標籤
3. 選擇要管理的競賽
### 查看評分記錄
1. 選擇競賽後,系統會自動載入該競賽的所有評分記錄
2. 使用篩選功能查看特定狀態的評分
3. 使用搜尋功能快速找到特定評審或參賽者的評分
### 動態評分輸入
1. 點擊「手動輸入評分」按鈕
2. 選擇評審和參賽者
3. 根據競賽設定的評比項目進行評分
4. 為每個評分項目選擇分數1-10分
5. 系統會根據權重自動計算總分
6. 填寫評審意見
7. 點擊「提交評分」完成評分
### 編輯現有評分
1. 在評分記錄表格中點擊編輯按鈕
2. 修改評審意見
3. 點擊「更新評分」保存修改
## 技術實現
### 組件結構
- `ScoringManagement`:主要評分管理組件
- 整合到現有的 `CompetitionManagement` 組件中
### 動態評分系統
- **評比規則讀取**:從競賽的 `rules` 屬性讀取評比項目
- **動態評分項目生成**:根據競賽規則動態生成評分表單
- **權重計算**:支援不同評分項目的權重設定
- **評分驗證**:確保所有評分項目都已評分
- **總分計算**:根據權重自動計算總分
### 數據流
1.`useCompetition` 上下文獲取競賽和評分數據
2. 根據選擇的競賽載入相關的評審和參賽者
3. 讀取競賽的評比規則並動態生成評分項目
4. 生成評分記錄列表
5. 支援篩選、搜尋和分頁功能
### 狀態管理
- 使用 React hooks 管理組件狀態
- 整合現有的競賽上下文
- 支援即時數據更新
- 動態評分項目的狀態管理
## 文件結構
```
components/admin/
├── scoring-management.tsx # 評分管理組件
└── competition-management.tsx # 競賽管理組件(已整合)
app/admin/
└── scoring/
└── page.tsx # 評分管理頁面
```
## 注意事項
1. 評分記錄會根據競賽的評審和參賽者自動生成
2. 已完成的評分可以編輯,未完成的評分可以新增
3. 評分提交後會即時更新列表
4. 支援個人賽和團隊賽的評分管理
5. 評分數據與現有的競賽管理系統完全整合
6. 評分項目會根據競賽建立時設定的評比規則動態生成
7. 如果競賽沒有設定評比規則,會使用預設的評分項目
8. 總分會根據各評分項目的權重自動計算
9. 系統會驗證所有評分項目都已評分才能提交

670
SOFTWARE_SPECIFICATION.md Normal file
View File

@@ -0,0 +1,670 @@
# AI展示平台軟體規格書
## 1. 專案概述
### 1.1 專案名稱
AI展示平台 (AI Showcase Platform)
### 1.2 專案目標
建立一個企業內部的AI應用展示、競賽管理和評審系統促進AI技術的創新與應用。
### 1.3 專案範圍
- 用戶認證與權限管理
- AI應用展示與管理
- 競賽系統與評審流程
- 團隊協作與提案管理
- 數據分析與報表生成
- 管理員後台系統
- AI智能助手系統
## 2. 系統架構
### 2.1 技術棧
#### 前端技術
- **框架**: Next.js 15.2.4 (App Router)
- **語言**: TypeScript 5
- **UI庫**:
- Radix UI (無障礙組件)
- shadcn/ui (設計系統)
- Tailwind CSS (樣式框架)
- **狀態管理**: React Context API
- **表單處理**: React Hook Form + Zod
- **圖表**: Recharts
- **包管理器**: pnpm
#### 開發工具
- **代碼品質**: ESLint + TypeScript
- **樣式處理**: PostCSS + Tailwind CSS
- **圖標**: Lucide React
- **版本控制**: Git
### 2.2 目錄結構
```
ai-showcase-platform/
├── app/ # Next.js App Router
│ ├── admin/ # 管理員頁面
│ ├── competition/ # 競賽頁面
│ ├── judge-scoring/ # 評審評分頁面
│ ├── register/ # 註冊頁面
│ └── globals.css # 全域樣式
├── components/ # React 組件
│ ├── admin/ # 管理員專用組件
│ ├── auth/ # 認證相關組件
│ ├── competition/ # 競賽相關組件
│ ├── reviews/ # 評論系統組件
│ ├── chat-bot.tsx # AI智能助手組件
│ └── ui/ # 通用UI組件
├── contexts/ # React Context
│ ├── auth-context.tsx # 認證狀態管理
│ └── competition-context.tsx # 競賽狀態管理
├── hooks/ # 自定義 Hooks
├── lib/ # 工具函數
├── types/ # TypeScript 類型定義
└── public/ # 靜態資源
```
## 3. 功能需求
### 3.1 用戶管理系統
#### 3.1.1 用戶角色
- **一般用戶 (user)**: 瀏覽應用、參與投票
- **開發者 (developer)**: 提交AI應用、參與競賽
- **管理員 (admin)**: 系統管理、數據分析
#### 3.1.2 用戶功能
- 註冊/登入/登出
- 個人資料管理
- 收藏應用
- 按讚功能 (每日限制)
- 瀏覽記錄
- 權限控制
### 3.2 競賽系統
#### 3.2.1 競賽類型
- **個人賽 (individual)**: 個人開發者競賽
- **團隊賽 (team)**: 團隊協作競賽
- **提案賽 (proposal)**: 創新提案競賽
- **混合賽 (mixed)**: 綜合性競賽
#### 3.2.2 競賽狀態
- **upcoming**: 即將開始
- **active**: 進行中
- **judging**: 評審中
- **completed**: 已完成
#### 3.2.3 評審系統
- 多維度評分 (創新性、技術性、實用性、展示效果、影響力)
- 評審管理
- 分數統計與排名
- 評審意見記錄
### 3.3 獎項系統
#### 3.3.1 獎項類型
- **金獎/銀獎/銅獎**: 排名獎項
- **最佳創新獎**: 創新性獎項
- **最佳技術獎**: 技術實現獎項
- **人氣獎**: 受歡迎程度獎項
- **自定義獎項**: 可配置的獎項
#### 3.3.2 獎項分類
- **innovation**: 創新類
- **technical**: 技術類
- **practical**: 實用類
- **popular**: 人氣類
- **teamwork**: 團隊協作類
- **solution**: 解決方案類
- **creativity**: 創意類
### 3.4 管理員系統
#### 3.4.1 用戶管理
- 用戶列表查看
- 用戶權限管理
- 用戶資料編輯
- 用戶統計分析
#### 3.4.2 競賽管理
- 競賽創建與編輯
- 競賽狀態管理
- 參賽者管理
- 評審分配
#### 3.4.3 評審管理
- 評審帳號管理
- 評審分配
- 評分進度追蹤
- 評審意見管理
#### 3.4.4 數據分析
- 競賽統計
- 用戶活躍度分析
- 應用熱度分析
- 評分趨勢分析
### 3.5 AI智能助手系統
#### 3.5.1 核心功能
- **即時對話**: 與AI助手進行自然語言對話
- **智能回答**: 基於DeepSeek API的智能回應
- **快速問題**: 提供相關問題的快速選擇
- **上下文記憶**: 保持對話的連續性
#### 3.5.2 對話能力
- **前台功能指導**: 註冊、提交作品、投票、收藏等
- **後台管理協助**: 競賽創建、評審管理、評分系統等
- **系統使用指南**: 提供具體的操作步驟
- **問題分類處理**: 根據問題類型提供相關建議
#### 3.5.3 用戶體驗
- **浮動按鈕**: 固定在右下角的聊天入口
- **模態對話框**: 全屏遮罩的聊天界面
- **即時反饋**: 輸入狀態和載入動畫
- **響應式設計**: 適配不同螢幕尺寸
#### 3.5.4 技術特性
- **API整合**: 與DeepSeek Chat API無縫整合
- **內容清理**: 自動清理Markdown格式和過長文字
- **錯誤處理**: 網路錯誤和API錯誤的優雅處理
- **性能優化**: 限制token數量以獲得更簡潔的回答
## 4. 數據模型
### 4.1 用戶模型
```typescript
interface User {
id: string
name: string
email: string
avatar?: string
department: string
role: "user" | "developer" | "admin"
joinDate: string
favoriteApps: string[]
recentApps: string[]
totalLikes: number
totalViews: number
}
```
### 4.2 競賽模型
```typescript
interface Competition {
id: string
name: string
year: number
month: number
startDate: string
endDate: string
status: "upcoming" | "active" | "judging" | "completed"
description: string
type: "individual" | "team" | "mixed" | "proposal"
judges: string[]
participatingApps: string[]
participatingTeams: string[]
participatingProposals: string[]
rules: CompetitionRule[]
awardTypes: CompetitionAwardType[]
evaluationFocus: string
maxTeamSize?: number
}
```
### 4.3 評審模型
```typescript
interface Judge {
id: string
name: string
title: string
department: string
expertise: string[]
avatar?: string
}
interface JudgeScore {
judgeId: string
appId: string
scores: {
innovation: number
technical: number
usability: number
presentation: number
impact: number
}
comments: string
submittedAt: string
}
```
### 4.4 團隊模型
```typescript
interface TeamMember {
id: string
name: string
department: string
role: string
}
interface Team {
id: string
name: string
members: TeamMember[]
leader: string
department: string
contactEmail: string
apps: string[]
totalLikes: number
}
```
### 4.5 獎項模型
```typescript
interface Award {
id: string
competitionId: string
appId?: string
teamId?: string
proposalId?: string
appName?: string
teamName?: string
proposalTitle?: string
creator: string
awardType: "gold" | "silver" | "bronze" | "popular" | "innovation" | "technical" | "custom"
awardName: string
score: number
year: number
month: number
icon: string
customAwardTypeId?: string
competitionType: "individual" | "team" | "proposal"
rank: number
category: "innovation" | "technical" | "practical" | "popular" | "teamwork" | "solution" | "creativity"
}
```
### 4.6 AI助手模型
```typescript
interface Message {
id: string
text: string
sender: "user" | "bot"
timestamp: Date
quickQuestions?: string[]
}
interface ChatSession {
id: string
userId: string
messages: Message[]
createdAt: Date
updatedAt: Date
}
interface AIAssistantConfig {
apiKey: string
apiUrl: string
model: string
maxTokens: number
temperature: number
systemPrompt: string
}
```
## 5. API 設計
### 5.1 認證 API
```
POST /api/auth/login # 用戶登入
POST /api/auth/register # 用戶註冊
POST /api/auth/logout # 用戶登出
GET /api/auth/profile # 獲取用戶資料
PUT /api/auth/profile # 更新用戶資料
```
### 5.2 競賽 API
```
GET /api/competitions # 獲取競賽列表
POST /api/competitions # 創建競賽
GET /api/competitions/:id # 獲取競賽詳情
PUT /api/competitions/:id # 更新競賽
DELETE /api/competitions/:id # 刪除競賽
GET /api/competitions/:id/scores # 獲取競賽評分
POST /api/competitions/:id/scores # 提交評分
```
### 5.3 用戶 API
```
GET /api/users # 獲取用戶列表
GET /api/users/:id # 獲取用戶詳情
PUT /api/users/:id # 更新用戶資料
DELETE /api/users/:id # 刪除用戶
GET /api/users/:id/apps # 獲取用戶應用
GET /api/users/:id/teams # 獲取用戶團隊
```
### 5.4 評審 API
```
GET /api/judges # 獲取評審列表
POST /api/judges # 創建評審帳號
GET /api/judges/:id # 獲取評審詳情
PUT /api/judges/:id # 更新評審資料
DELETE /api/judges/:id # 刪除評審
GET /api/judges/:id/scores # 獲取評審評分
POST /api/judges/:id/scores # 提交評審評分
```
### 5.5 團隊 API
```
GET /api/teams # 獲取團隊列表
POST /api/teams # 創建團隊
GET /api/teams/:id # 獲取團隊詳情
PUT /api/teams/:id # 更新團隊資料
DELETE /api/teams/:id # 刪除團隊
GET /api/teams/:id/members # 獲取團隊成員
POST /api/teams/:id/members # 添加團隊成員
```
### 5.6 獎項 API
```
GET /api/awards # 獲取獎項列表
POST /api/awards # 創建獎項
GET /api/awards/:id # 獲取獎項詳情
PUT /api/awards/:id # 更新獎項
DELETE /api/awards/:id # 刪除獎項
GET /api/awards/by-year/:year # 按年份獲取獎項
GET /api/awards/by-type/:type # 按類型獲取獎項
```
### 5.7 AI助手 API
```
POST /api/chat/send # 發送聊天訊息
GET /api/chat/history # 獲取聊天歷史
DELETE /api/chat/history # 清除聊天歷史
POST /api/chat/feedback # 提交聊天反饋
GET /api/chat/quick-questions # 獲取快速問題建議
```
## 6. 數據庫設計
### 6.1 用戶表 (users)
```sql
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
avatar VARCHAR(500),
department VARCHAR(100) NOT NULL,
role ENUM('user', 'developer', 'admin') DEFAULT 'user',
join_date DATE NOT NULL,
total_likes INT DEFAULT 0,
total_views INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### 6.2 競賽表 (competitions)
```sql
CREATE TABLE competitions (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
status ENUM('upcoming', 'active', 'judging', 'completed') DEFAULT 'upcoming',
description TEXT,
type ENUM('individual', 'team', 'mixed', 'proposal') NOT NULL,
evaluation_focus TEXT,
max_team_size INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### 6.3 評審表 (judges)
```sql
CREATE TABLE judges (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
title VARCHAR(100) NOT NULL,
department VARCHAR(100) NOT NULL,
expertise JSON,
avatar VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
### 6.4 評分表 (judge_scores)
```sql
CREATE TABLE judge_scores (
id VARCHAR(36) PRIMARY KEY,
judge_id VARCHAR(36) NOT NULL,
app_id VARCHAR(36),
proposal_id VARCHAR(36),
scores JSON NOT NULL,
comments TEXT,
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (judge_id) REFERENCES judges(id),
FOREIGN KEY (app_id) REFERENCES apps(id),
FOREIGN KEY (proposal_id) REFERENCES proposals(id)
);
```
### 6.5 團隊表 (teams)
```sql
CREATE TABLE teams (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
leader_id VARCHAR(36) NOT NULL,
department VARCHAR(100) NOT NULL,
contact_email VARCHAR(255) NOT NULL,
total_likes INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (leader_id) REFERENCES users(id)
);
```
### 6.6 團隊成員表 (team_members)
```sql
CREATE TABLE team_members (
id VARCHAR(36) PRIMARY KEY,
team_id VARCHAR(36) NOT NULL,
user_id VARCHAR(36) NOT NULL,
role VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### 6.7 應用表 (apps)
```sql
CREATE TABLE apps (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
creator_id VARCHAR(36) NOT NULL,
team_id VARCHAR(36),
likes_count INT DEFAULT 0,
views_count INT DEFAULT 0,
rating DECIMAL(3,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (creator_id) REFERENCES users(id),
FOREIGN KEY (team_id) REFERENCES teams(id)
);
```
### 6.8 獎項表 (awards)
```sql
CREATE TABLE awards (
id VARCHAR(36) PRIMARY KEY,
competition_id VARCHAR(36) NOT NULL,
app_id VARCHAR(36),
team_id VARCHAR(36),
proposal_id VARCHAR(36),
award_type ENUM('gold', 'silver', 'bronze', 'popular', 'innovation', 'technical', 'custom') NOT NULL,
award_name VARCHAR(200) NOT NULL,
score DECIMAL(5,2) NOT NULL,
year INT NOT NULL,
month INT NOT NULL,
icon VARCHAR(50),
custom_award_type_id VARCHAR(36),
competition_type ENUM('individual', 'team', 'proposal') NOT NULL,
rank INT DEFAULT 0,
category ENUM('innovation', 'technical', 'practical', 'popular', 'teamwork', 'solution', 'creativity') NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (competition_id) REFERENCES competitions(id),
FOREIGN KEY (app_id) REFERENCES apps(id),
FOREIGN KEY (team_id) REFERENCES teams(id)
);
```
### 6.9 聊天會話表 (chat_sessions)
```sql
CREATE TABLE chat_sessions (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### 6.10 聊天訊息表 (chat_messages)
```sql
CREATE TABLE chat_messages (
id VARCHAR(36) PRIMARY KEY,
session_id VARCHAR(36) NOT NULL,
text TEXT NOT NULL,
sender ENUM('user', 'bot') NOT NULL,
quick_questions JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES chat_sessions(id)
);
```
### 6.11 AI助手配置表 (ai_assistant_configs)
```sql
CREATE TABLE ai_assistant_configs (
id VARCHAR(36) PRIMARY KEY,
api_key VARCHAR(255) NOT NULL,
api_url VARCHAR(500) NOT NULL,
model VARCHAR(100) NOT NULL,
max_tokens INT DEFAULT 200,
temperature DECIMAL(3,2) DEFAULT 0.7,
system_prompt TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
## 7. 非功能性需求
### 7.1 性能需求
- 頁面載入時間 < 3秒
- 支持同時1000+用戶在線
- 數據庫查詢響應時間 < 500ms
- 圖片優化和CDN加速
- AI助手回應時間 < 5秒
- 聊天訊息實時更新
### 7.2 安全需求
- 用戶密碼加密存儲
- JWT Token認證
- CSRF防護
- XSS防護
- SQL注入防護
- 權限驗證
- AI API密鑰安全存儲
- 聊天數據隱私保護
### 7.3 可用性需求
- 系統可用性 > 99.5%
- 響應式設計,支持多設備
- 無障礙設計 (WCAG 2.1)
- 多語言支持準備
### 7.4 可維護性需求
- 模組化架構
- 完整的API文檔
- 代碼註釋和文檔
- 單元測試覆蓋率 > 80%
- 錯誤日誌和監控
## 8. 部署架構
### 8.1 開發環境
- **前端**: Next.js 開發服務器
- **後端**: Node.js/Express 或 Python/FastAPI
- **數據庫**: PostgreSQL 或 MySQL
- **緩存**: Redis
- **文件存儲**: 本地存儲或雲存儲
### 8.2 生產環境
- **前端**: Vercel 或 AWS S3 + CloudFront
- **後端**: AWS EC2 或 Docker 容器
- **數據庫**: AWS RDS 或自建數據庫
- **緩存**: AWS ElastiCache (Redis)
- **文件存儲**: AWS S3
- **CDN**: CloudFront 或 Cloudflare
## 9. 開發計劃
### 9.1 第一階段 (4週)
- [x] 前端架構搭建
- [x] 基礎組件開發
- [x] 認證系統實現
- [x] 競賽管理基礎功能
### 9.2 第二階段 (4週)
- [ ] 後端API開發
- [ ] 數據庫設計與實現
- [ ] 評審系統完善
- [ ] 獎項系統實現
### 9.3 第三階段 (3週)
- [ ] 數據分析功能
- [ ] 管理員後台完善
- [ ] 性能優化
- [ ] 安全加固
### 9.4 第四階段 (2週)
- [ ] 測試與調試
- [ ] 文檔完善
- [ ] 部署上線
- [ ] 用戶培訓
## 10. 風險評估
### 10.1 技術風險
- **數據庫性能**: 大量數據查詢可能影響性能
- **並發處理**: 高並發場景下的數據一致性
- **安全性**: 用戶數據保護和系統安全
### 10.2 項目風險
- **時間風險**: 開發進度可能延遲
- **需求變更**: 功能需求可能調整
- **資源風險**: 開發資源不足
### 10.3 緩解措施
- 採用成熟的技術棧
- 實施敏捷開發方法
- 建立完善的測試體系
- 制定詳細的項目計劃
- 定期進行代碼審查
---
**文檔版本**: v1.0
**最後更新**: 2025年07月
**負責人**: 敏捷小組 - 佩庭
**審核人**: 強茂集團

7
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
"use client"
import { AdminPanel } from "@/components/admin/admin-panel"
export default function AdminPage() {
return <AdminPanel />
}

View File

@@ -0,0 +1,195 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { CheckCircle, Edit, Loader2 } from "lucide-react"
export default function ScoringFormTestPage() {
const [showScoringForm, setShowScoringForm] = useState(false)
const [manualScoring, setManualScoring] = useState({
judgeId: "judge1",
participantId: "app1",
scores: {
"創新性": 0,
"技術性": 0,
"實用性": 0,
"展示效果": 0,
"影響力": 0
},
comments: ""
})
const [isLoading, setIsLoading] = useState(false)
const scoringRules = [
{ name: "創新性", description: "技術創新程度和獨特性", weight: 25 },
{ name: "技術性", description: "技術實現的複雜度和穩定性", weight: 20 },
{ name: "實用性", description: "實際應用價值和用戶體驗", weight: 25 },
{ name: "展示效果", description: "演示效果和表達能力", weight: 15 },
{ name: "影響力", description: "對行業和社會的潛在影響", weight: 15 }
]
const calculateTotalScore = (scores: Record<string, number>): number => {
let totalScore = 0
let totalWeight = 0
scoringRules.forEach(rule => {
const score = scores[rule.name] || 0
const weight = rule.weight || 1
totalScore += score * weight
totalWeight += weight
})
return totalWeight > 0 ? Math.round(totalScore / totalWeight) : 0
}
const handleSubmitScore = async () => {
setIsLoading(true)
// 模擬提交
setTimeout(() => {
setIsLoading(false)
setShowScoringForm(false)
}, 2000)
}
return (
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold"></h1>
<p className="text-gray-600"></p>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => setShowScoringForm(true)} size="lg">
<Edit className="w-5 h-5 mr-2" />
</Button>
</CardContent>
</Card>
<Dialog open={showScoringForm} onOpenChange={setShowScoringForm}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Edit className="w-5 h-5" />
<span></span>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 評分項目 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
{scoringRules.map((rule, index) => (
<div key={index} className="space-y-4 p-6 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1">
<Label className="text-lg font-semibold text-gray-900">{rule.name}</Label>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{rule.description}</p>
<p className="text-xs text-purple-600 mt-2 font-medium">{rule.weight}%</p>
</div>
<div className="text-right ml-4">
<span className="text-2xl font-bold text-blue-600">
{manualScoring.scores[rule.name] || 0} / 10
</span>
</div>
</div>
{/* 評分按鈕 */}
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<button
key={score}
type="button"
onClick={() => setManualScoring({
...manualScoring,
scores: { ...manualScoring.scores, [rule.name]: score }
})}
className={`w-12 h-12 rounded-lg border-2 font-semibold text-lg transition-all duration-200 ${
(manualScoring.scores[rule.name] || 0) === score
? 'bg-blue-600 text-white border-blue-600 shadow-lg scale-105'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50 hover:scale-105'
}`}
>
{score}
</button>
))}
</div>
</div>
))}
</div>
{/* 總分顯示 */}
<div className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200">
<div className="flex justify-between items-center">
<div>
<span className="text-xl font-bold text-gray-900"></span>
<p className="text-sm text-gray-600 mt-1"></p>
</div>
<div className="flex items-center space-x-3">
<span className="text-4xl font-bold text-blue-600">
{calculateTotalScore(manualScoring.scores)}
</span>
<span className="text-xl text-gray-500 font-medium">/ 10</span>
</div>
</div>
</div>
{/* 評審意見 */}
<div className="space-y-3">
<Label className="text-lg font-semibold"> *</Label>
<Textarea
placeholder="請詳細填寫評審意見、優點分析、改進建議等..."
value={manualScoring.comments}
onChange={(e) => setManualScoring({ ...manualScoring, comments: e.target.value })}
rows={6}
className="min-h-[120px] resize-none"
/>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6 border-t border-gray-200">
<Button
variant="outline"
size="lg"
onClick={() => setShowScoringForm(false)}
className="px-8"
>
</Button>
<Button
onClick={handleSubmitScore}
disabled={isLoading}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
<>
<CheckCircle className="w-5 h-5 mr-2" />
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { ScoringManagement } from "@/components/admin/scoring-management"
export default function ScoringTestPage() {
return (
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold"></h1>
<p className="text-gray-600"></p>
</div>
<ScoringManagement />
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { ScoringManagement } from "@/components/admin/scoring-management"
export default function ScoringPage() {
return (
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold"></h1>
<p className="text-gray-600"></p>
</div>
<ScoringManagement />
</div>
)
}

572
app/competition/page.tsx Normal file
View File

@@ -0,0 +1,572 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useCompetition } from "@/contexts/competition-context"
import { Trophy, Award, Medal, Target, Users, Lightbulb, ArrowLeft, Plus, Search, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { PopularityRankings } from "@/components/competition/popularity-rankings"
import { CompetitionDetailDialog } from "@/components/competition/competition-detail-dialog"
import { AwardDetailDialog } from "@/components/competition/award-detail-dialog"
export default function CompetitionPage() {
const { user, canAccessAdmin } = useAuth()
const { competitions, awards, getAwardsByYear, getCompetitionRankings } = useCompetition()
const [selectedCompetitionTypeFilter, setSelectedCompetitionTypeFilter] = useState("all")
const [selectedMonthFilter, setSelectedMonthFilter] = useState("all")
const [selectedAwardCategory, setSelectedAwardCategory] = useState("all")
const [selectedYear, setSelectedYear] = useState(2024)
const [searchQuery, setSearchQuery] = useState("")
const [showCompetitionDetail, setShowCompetitionDetail] = useState(false)
const [selectedRanking, setSelectedRanking] = useState<any>(null)
const [selectedCompetitionType, setSelectedCompetitionType] = useState<"individual" | "team" | "proposal">(
"individual",
)
const [showAwardDetail, setShowAwardDetail] = useState(false)
const [selectedAward, setSelectedAward] = useState<any>(null)
const getCompetitionTypeIcon = (type: string) => {
switch (type) {
case "individual":
return <Target className="w-4 h-4" />
case "team":
return <Users className="w-4 h-4" />
case "proposal":
return <Lightbulb className="w-4 h-4" />
case "mixed":
return <Trophy className="w-4 h-4" />
default:
return <Trophy className="w-4 h-4" />
}
}
const getCompetitionTypeText = (type: string) => {
switch (type) {
case "individual":
return "個人賽"
case "team":
return "團隊賽"
case "proposal":
return "提案賽"
case "mixed":
return "混合賽"
default:
return "競賽"
}
}
const getCompetitionTypeColor = (type: string) => {
switch (type) {
case "individual":
return "bg-blue-100 text-blue-800 border-blue-200"
case "team":
return "bg-green-100 text-green-800 border-green-200"
case "proposal":
return "bg-purple-100 text-purple-800 border-purple-200"
case "mixed":
return "bg-gradient-to-r from-blue-100 via-green-100 to-purple-100 text-gray-800 border-gray-200"
default:
return "bg-gray-100 text-gray-800 border-gray-200"
}
}
const handleShowCompetitionDetail = (ranking: any, type: "individual" | "team" | "proposal") => {
setSelectedRanking(ranking)
setSelectedCompetitionType(type)
setShowCompetitionDetail(true)
}
const handleShowAwardDetail = (award: any) => {
setSelectedAward(award)
setShowAwardDetail(true)
}
const getFilteredAwards = () => {
let filteredAwards = getAwardsByYear(selectedYear)
// 搜索功能 - 按应用名称、创作者或奖项名称搜索
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filteredAwards = filteredAwards.filter((award) => {
return (
award.appName?.toLowerCase().includes(query) ||
award.creator?.toLowerCase().includes(query) ||
award.awardName?.toLowerCase().includes(query)
)
})
}
if (selectedCompetitionTypeFilter !== "all") {
filteredAwards = filteredAwards.filter((award) => award.competitionType === selectedCompetitionTypeFilter)
}
if (selectedMonthFilter !== "all") {
filteredAwards = filteredAwards.filter((award) => award.month === Number.parseInt(selectedMonthFilter))
}
if (selectedAwardCategory !== "all") {
if (selectedAwardCategory === "ranking") {
filteredAwards = filteredAwards.filter((award) => award.rank > 0 && award.rank <= 3)
} else if (selectedAwardCategory === "popular") {
filteredAwards = filteredAwards.filter((award) => award.awardType === "popular")
} else {
filteredAwards = filteredAwards.filter((award) => award.category === selectedAwardCategory)
}
}
return filteredAwards.sort((a, b) => {
// Sort by month first, then by rank
if (a.month !== b.month) return b.month - a.month
if (a.rank !== b.rank) {
if (a.rank === 0) return 1
if (b.rank === 0) return -1
return a.rank - b.rank
}
return 0
})
}
const filteredAwards = getFilteredAwards()
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50">
{/* Header */}
<header className="bg-white/80 backdrop-blur-sm border-b border-gray-200 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => window.history.back()}
className="text-gray-700 hover:text-blue-600 hover:bg-blue-50"
>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<Trophy className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-xs text-gray-500">COMPETITION CENTER</p>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Hero Section */}
<div className="text-center mb-12">
<h2 className="text-4xl font-bold text-gray-900 mb-4">AI </h2>
<p className="text-xl text-gray-600 max-w-3xl mx-auto mb-8">
AI 耀
</p>
</div>
<Tabs defaultValue="rankings" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-8">
<TabsTrigger value="rankings" className="flex items-center space-x-2">
<Trophy className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="awards" className="flex items-center space-x-2">
<Award className="w-4 h-4" />
<span></span>
</TabsTrigger>
</TabsList>
<TabsContent value="rankings">
<PopularityRankings />
</TabsContent>
<TabsContent value="awards">
<div className="space-y-8">
{/* Enhanced Filter Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Medal className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<div className="flex items-center space-x-3">
<Select
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(Number.parseInt(value))}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2024">2024</SelectItem>
<SelectItem value="2023">2023</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-gray-600"> {selectedYear} </p>
{searchQuery && (
<div className="text-sm text-blue-600 bg-blue-50 px-3 py-1 rounded-full">
{searchQuery}
</div>
)}
</div>
{/* Search and Filter Controls */}
<div className="space-y-4">
{/* Search Bar */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<Input
type="text"
placeholder="搜尋應用名稱、創作者或獎項名稱..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-10 w-full md:w-96"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Filter Controls */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedCompetitionTypeFilter} onValueChange={setSelectedCompetitionTypeFilter}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="individual"></SelectItem>
<SelectItem value="team"></SelectItem>
<SelectItem value="proposal"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedMonthFilter} onValueChange={setSelectedMonthFilter}>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="6">6</SelectItem>
<SelectItem value="7">7</SelectItem>
<SelectItem value="8">8</SelectItem>
<SelectItem value="9">9</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="11">11</SelectItem>
<SelectItem value="12">12</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAwardCategory} onValueChange={setSelectedAwardCategory}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="ranking"></SelectItem>
<SelectItem value="popular"></SelectItem>
<SelectItem value="innovation"></SelectItem>
<SelectItem value="technical"></SelectItem>
<SelectItem value="practical"></SelectItem>
</SelectContent>
</Select>
</div>
{/* Clear Filters Button */}
{(searchQuery || selectedCompetitionTypeFilter !== "all" || selectedMonthFilter !== "all" || selectedAwardCategory !== "all") && (
<div className="flex items-center">
<Button
variant="outline"
size="sm"
onClick={() => {
setSearchQuery("")
setSelectedCompetitionTypeFilter("all")
setSelectedMonthFilter("all")
setSelectedAwardCategory("all")
}}
className="text-gray-600 hover:text-gray-800"
>
<X className="w-4 h-4 mr-1" />
</Button>
</div>
)}
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-lg font-bold text-blue-600">{filteredAwards.length}</div>
<div className="text-xs text-blue-600"></div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-lg font-bold text-yellow-600">
{filteredAwards.filter((a) => a.rank > 0 && a.rank <= 3).length}
</div>
<div className="text-xs text-yellow-600"></div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-lg font-bold text-red-600">
{filteredAwards.filter((a) => a.awardType === "popular").length}
</div>
<div className="text-xs text-red-600"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-lg font-bold text-green-600">
{new Set(filteredAwards.map((a) => `${a.year}-${a.month}`)).size}
</div>
<div className="text-xs text-green-600"></div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Awards Grid with Enhanced Display */}
{filteredAwards.length > 0 ? (
<div className="space-y-8">
{/* Group awards by month */}
{Array.from(new Set(filteredAwards.map((award) => award.month)))
.sort((a, b) => b - a)
.map((month) => {
const monthAwards = filteredAwards.filter((award) => award.month === month)
const competition = competitions.find((c) => c.month === month && c.year === selectedYear)
return (
<div key={month} className="space-y-4">
<div className="flex items-center space-x-4">
<h3 className="text-xl font-bold text-gray-900">
{selectedYear}{month}
</h3>
{competition && (
<Badge variant="outline" className={getCompetitionTypeColor(competition.type)}>
{getCompetitionTypeIcon(competition.type)}
<span className="ml-1">{getCompetitionTypeText(competition.type)}</span>
</Badge>
)}
<Badge variant="secondary" className="bg-gray-100 text-gray-700">
{monthAwards.length}
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{monthAwards.map((award) => (
<Card
key={award.id}
className="relative overflow-hidden border-0 shadow-lg bg-gradient-to-br from-white to-gray-50 hover:shadow-xl transition-shadow cursor-pointer"
onClick={() => handleShowAwardDetail(award)}
>
{/* Rank Badge */}
{award.rank > 0 && award.rank <= 3 && (
<div className="absolute top-2 left-2 z-10">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold text-white ${
award.rank === 1
? "bg-yellow-500"
: award.rank === 2
? "bg-gray-400"
: award.rank === 3
? "bg-orange-600"
: ""
}`}
>
{award.rank}
</div>
</div>
)}
<div className="absolute top-4 right-4 text-3xl">{award.icon}</div>
<CardHeader className="pb-3 pt-12">
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Badge
variant="secondary"
className={`w-fit ${
award.awardType === "popular"
? "bg-red-100 text-red-800 border-red-200"
: award.rank === 1
? "bg-yellow-100 text-yellow-800 border-yellow-200"
: award.rank === 2
? "bg-gray-100 text-gray-800 border-gray-200"
: award.rank === 3
? "bg-orange-100 text-orange-800 border-orange-200"
: "bg-blue-100 text-blue-800 border-blue-200"
}`}
>
{award.awardName}
</Badge>
<Badge
variant="outline"
className={getCompetitionTypeColor(award.competitionType)}
>
{getCompetitionTypeIcon(award.competitionType)}
<span className="ml-1">{getCompetitionTypeText(award.competitionType)}</span>
</Badge>
</div>
<CardTitle className="text-lg line-clamp-2">
{award.appName || award.proposalTitle || award.teamName}
</CardTitle>
<p className="text-sm text-gray-500">by {award.creator}</p>
<div className="text-xs text-gray-400">
{award.year}{award.month}
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">
{award.competitionType === "proposal"
? "評審評分"
: award.awardType === "popular"
? award.competitionType === "team"
? "人氣指數"
: "收藏數"
: "評審評分"}
</span>
<span className="font-bold text-lg text-gray-900">
{award.awardType === "popular" && award.competitionType === "team"
? `${award.score}`
: award.awardType === "popular"
? `${award.score}`
: award.score}
</span>
</div>
<Button
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
onClick={(e) => {
e.stopPropagation()
handleShowAwardDetail(award)
}}
>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
})}
</div>
) : (
<Card>
<CardContent className="text-center py-12">
<div className="space-y-4">
{searchQuery ? (
<Search className="w-16 h-16 text-gray-400 mx-auto" />
) : (
<Medal className="w-16 h-16 text-gray-400 mx-auto" />
)}
<div>
<h3 className="text-xl font-semibold text-gray-600 mb-2">
{searchQuery ? (
<>{searchQuery}</>
) : (
<>
{selectedYear}{selectedMonthFilter !== "all" ? `${selectedMonthFilter}` : ""}
</>
)}
</h3>
<p className="text-gray-500">
{searchQuery
? "嘗試使用其他關鍵字或調整篩選條件"
: "請調整篩選條件查看其他得獎作品"}
</p>
</div>
<div className="flex justify-center gap-2">
<Button
variant="outline"
className="bg-transparent"
onClick={() => {
setSearchQuery("")
setSelectedCompetitionTypeFilter("all")
setSelectedMonthFilter("all")
setSelectedAwardCategory("all")
}}
>
<X className="w-4 h-4 mr-1" />
</Button>
{searchQuery && (
<Button
variant="outline"
className="bg-transparent"
onClick={() => setSearchQuery("")}
>
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
</TabsContent>
</Tabs>
{/* Competition Detail Dialog */}
{selectedRanking && (
<CompetitionDetailDialog
open={showCompetitionDetail}
onOpenChange={setShowCompetitionDetail}
ranking={selectedRanking}
competitionType={selectedCompetitionType}
/>
)}
{/* Award Detail Dialog */}
{selectedAward && (
<AwardDetailDialog open={showAwardDetail} onOpenChange={setShowAwardDetail} award={selectedAward} />
)}
</div>
</div>
)
}

100
app/globals.css Normal file
View File

@@ -0,0 +1,100 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 隱藏滾動條 */
.scrollbar-hide {
-ms-overflow-style: none; /* Internet Explorer 10+ */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

384
app/judge-scoring/page.tsx Normal file
View File

@@ -0,0 +1,384 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { AlertTriangle, CheckCircle, User, Trophy, LogIn, Loader2 } from "lucide-react"
interface Judge {
id: string
name: string
specialty: string
}
interface ScoringItem {
id: string
name: string
type: "individual" | "team"
status: "pending" | "completed"
score?: number
submittedAt?: string
}
export default function JudgeScoringPage() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [judgeId, setJudgeId] = useState("")
const [accessCode, setAccessCode] = useState("")
const [currentJudge, setCurrentJudge] = useState<Judge | null>(null)
const [scoringItems, setScoringItems] = useState<ScoringItem[]>([])
const [selectedItem, setSelectedItem] = useState<ScoringItem | null>(null)
const [showScoringDialog, setShowScoringDialog] = useState(false)
const [scores, setScores] = useState<Record<string, number>>({})
const [comments, setComments] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
// Judge data - empty for production
const mockJudges: Judge[] = []
// Scoring items - empty for production
const mockScoringItems: ScoringItem[] = []
const handleLogin = () => {
setError("")
if (!judgeId.trim() || !accessCode.trim()) {
setError("請填寫評審ID和存取碼")
return
}
if (accessCode !== "judge2024") {
setError("存取碼錯誤")
return
}
const judge = mockJudges.find(j => j.id === judgeId)
if (!judge) {
setError("評審ID不存在")
return
}
setCurrentJudge(judge)
setScoringItems(mockScoringItems)
setIsLoggedIn(true)
setSuccess("登入成功!")
setTimeout(() => setSuccess(""), 3000)
}
const handleStartScoring = (item: ScoringItem) => {
setSelectedItem(item)
setScores({})
setComments("")
setShowScoringDialog(true)
}
const handleSubmitScore = async () => {
if (!selectedItem) return
setIsSubmitting(true)
// 模擬提交評分
setTimeout(() => {
setScoringItems(prev => prev.map(item =>
item.id === selectedItem.id
? { ...item, status: "completed", score: Object.values(scores).reduce((a, b) => a + b, 0) / Object.values(scores).length, submittedAt: new Date().toISOString() }
: item
))
setShowScoringDialog(false)
setSelectedItem(null)
setScores({})
setComments("")
setIsSubmitting(false)
setSuccess("評分提交成功!")
setTimeout(() => setSuccess(""), 3000)
}, 1000)
}
const getProgress = () => {
const total = scoringItems.length
const completed = scoringItems.filter(item => item.status === "completed").length
return { total, completed, percentage: total > 0 ? Math.round((completed / total) * 100) : 0 }
}
const progress = getProgress()
if (!isLoggedIn) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-4">
<Trophy className="w-8 h-8 text-blue-600" />
</div>
<CardTitle className="text-2xl"></CardTitle>
<CardDescription>
ID和存取碼進行登入
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="judgeId">ID</Label>
<Input
id="judgeId"
placeholder="例如j1"
value={judgeId}
onChange={(e) => setJudgeId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="accessCode"></Label>
<Input
id="accessCode"
type="password"
placeholder="請輸入存取碼"
value={accessCode}
onChange={(e) => setAccessCode(e.target.value)}
/>
</div>
<Button
onClick={handleLogin}
className="w-full"
size="lg"
>
<LogIn className="w-4 h-4 mr-2" />
</Button>
<div className="text-center text-sm text-gray-500">
<p>ID範例j1, j2, j3, j4, j5</p>
<p>judge2024</p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-6xl mx-auto space-y-6">
{/* 成功訊息 */}
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{/* 評審資訊 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Avatar className="w-12 h-12">
<AvatarFallback className="text-lg font-semibold">
{currentJudge?.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold">{currentJudge?.name}</h1>
<p className="text-gray-600">{currentJudge?.specialty}</p>
</div>
</div>
<Button
variant="outline"
onClick={() => setIsLoggedIn(false)}
>
</Button>
</div>
</CardHeader>
</Card>
{/* 評分進度 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span></span>
<span>{progress.completed} / {progress.total}</span>
</div>
<Progress value={progress.percentage} className="h-2" />
<div className="text-center">
<span className="text-2xl font-bold text-blue-600">{progress.percentage}%</span>
</div>
</div>
</CardContent>
</Card>
{/* 評分項目列表 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{scoringItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{item.type === "individual" ? (
<User className="w-4 h-4 text-blue-600" />
) : (
<div className="flex space-x-1">
<User className="w-4 h-4 text-green-600" />
<User className="w-4 h-4 text-green-600" />
</div>
)}
<span className="font-medium">{item.name}</span>
<Badge variant="outline">
{item.type === "individual" ? "個人" : "團隊"}
</Badge>
</div>
</div>
<div className="flex items-center space-x-4">
{item.status === "completed" ? (
<div className="text-center">
<div className="text-lg font-bold text-green-600">{item.score}</div>
<div className="text-xs text-gray-500">/ 10</div>
<div className="text-xs text-gray-500">{item.submittedAt}</div>
</div>
) : (
<Button
onClick={() => handleStartScoring(item)}
variant="outline"
size="sm"
>
</Button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 評分對話框 */}
<Dialog open={showScoringDialog} onOpenChange={setShowScoringDialog}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{selectedItem?.name}</DialogTitle>
<DialogDescription>
滿10
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 評分項目 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
{[
{ name: "創新性", description: "創新程度和獨特性" },
{ name: "技術性", description: "技術實現的複雜度和品質" },
{ name: "實用性", description: "實際應用價值和用戶體驗" },
{ name: "展示效果", description: "展示的清晰度和吸引力" },
{ name: "影響力", description: "對行業或社會的潛在影響" }
].map((criterion, index) => (
<div key={index} className="space-y-2">
<Label>{criterion.name}</Label>
<p className="text-sm text-gray-600">{criterion.description}</p>
<div className="flex space-x-2">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<button
key={score}
type="button"
onClick={() => setScores(prev => ({ ...prev, [criterion.name]: score }))}
className={`w-10 h-10 rounded border-2 font-semibold transition-all ${
scores[criterion.name] === score
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400'
}`}
>
{score}
</button>
))}
</div>
</div>
))}
</div>
{/* 評審意見 */}
<div className="space-y-2">
<Label></Label>
<Textarea
placeholder="請詳細填寫評審意見、優點分析、改進建議等..."
value={comments}
onChange={(e) => setComments(e.target.value)}
rows={4}
/>
</div>
{/* 總分顯示 */}
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex justify-between items-center">
<span className="font-semibold"></span>
<span className="text-2xl font-bold text-blue-600">
{Object.values(scores).length > 0
? Math.round(Object.values(scores).reduce((a, b) => a + b, 0) / Object.values(scores).length)
: 0
} / 10
</span>
</div>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6 border-t">
<Button
variant="outline"
onClick={() => setShowScoringDialog(false)}
>
</Button>
<Button
onClick={handleSubmitScore}
disabled={isSubmitting || Object.keys(scores).length < 5 || !comments.trim()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"提交評分"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

33
app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type React from "react"
import { Inter } from "next/font/google"
import "./globals.css"
import { AuthProvider } from "@/contexts/auth-context"
import { CompetitionProvider } from "@/contexts/competition-context"
import { Toaster } from "@/components/ui/toaster"
import { ChatBot } from "@/components/chat-bot"
const inter = Inter({ subsets: ["latin"] })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-TW">
<body className={inter.className}>
<AuthProvider>
<CompetitionProvider>
{children}
<Toaster />
<ChatBot />
</CompetitionProvider>
</AuthProvider>
</body>
</html>
)
}
export const metadata = {
generator: 'v0.dev'
};

7
app/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-purple-600"></div>
</div>
)
}

1017
app/page.tsx Normal file

File diff suppressed because it is too large Load Diff

3
app/register/loading.tsx Normal file
View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

408
app/register/page.tsx Normal file
View File

@@ -0,0 +1,408 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator"
import { Brain, User, Mail, Building, Lock, Loader2, CheckCircle, AlertTriangle, Shield, Code } from "lucide-react"
export default function RegisterPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { register, isLoading } = useAuth()
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
department: "",
})
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
// 從 URL 參數獲取邀請資訊
const invitationToken = searchParams.get("token")
const invitedEmail = searchParams.get("email")
const invitedRole = searchParams.get("role") || "user"
const isInvitedUser = !!(invitationToken && invitedEmail)
useEffect(() => {
if (isInvitedUser) {
setFormData((prev) => ({
...prev,
email: decodeURIComponent(invitedEmail),
}))
}
}, [isInvitedUser, invitedEmail])
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
setError("")
}
const getRoleText = (role: string) => {
switch (role) {
case "admin":
return "管理員"
case "developer":
return "開發者"
case "user":
return "一般用戶"
default:
return role
}
}
const getRoleIcon = (role: string) => {
switch (role) {
case "admin":
return <Shield className="w-4 h-4 text-purple-600" />
case "developer":
return <Code className="w-4 h-4 text-green-600" />
case "user":
return <User className="w-4 h-4 text-blue-600" />
default:
return <User className="w-4 h-4 text-blue-600" />
}
}
const getRoleColor = (role: string) => {
switch (role) {
case "admin":
return "bg-purple-100 text-purple-800 border-purple-200"
case "developer":
return "bg-green-100 text-green-800 border-green-200"
case "user":
return "bg-blue-100 text-blue-800 border-blue-200"
default:
return "bg-gray-100 text-gray-800 border-gray-200"
}
}
const getRoleDescription = (role: string) => {
switch (role) {
case "admin":
return "可以訪問管理後台,管理用戶和審核應用"
case "developer":
return "可以提交 AI 應用申請,參與平台建設"
case "user":
return "可以瀏覽和收藏應用,參與評價互動"
default:
return ""
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsSubmitting(true)
// 表單驗證
if (!formData.name || !formData.email || !formData.password || !formData.department) {
setError("請填寫所有必填欄位")
setIsSubmitting(false)
return
}
if (formData.password !== formData.confirmPassword) {
setError("密碼確認不一致")
setIsSubmitting(false)
return
}
if (formData.password.length < 6) {
setError("密碼長度至少需要 6 個字符")
setIsSubmitting(false)
return
}
try {
const success = await register({
name: formData.name,
email: formData.email,
password: formData.password,
department: formData.department,
})
if (success) {
setSuccess("註冊成功!正在跳轉...")
setTimeout(() => {
router.push("/")
}, 2000)
} else {
setError("註冊失敗,請檢查資料或聯繫管理員")
}
} catch (err) {
setError("註冊過程中發生錯誤,請稍後再試")
}
setIsSubmitting(false)
}
if (success) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="text-center py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4"> AI </p>
<p className="text-sm text-gray-500">...</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center space-x-2 mb-4">
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<Brain className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900"> AI </h1>
</div>
</div>
{isInvitedUser ? (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"></h2>
<p className="text-gray-600"></p>
</div>
) : (
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"></h2>
<p className="text-gray-600"> AI </p>
</div>
)}
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{isInvitedUser ? "請填寫您的個人資訊完成註冊" : "請填寫以下資訊建立您的帳戶"}
</CardDescription>
</CardHeader>
<CardContent>
{/* Invitation Info */}
{isInvitedUser && (
<div className="mb-6">
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-start space-x-3">
<CheckCircle className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium text-blue-900 mb-2"></h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-blue-700"></span>
<span className="text-sm font-medium text-blue-900">{invitedEmail}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-blue-700"></span>
<Badge variant="outline" className={getRoleColor(invitedRole)}>
<div className="flex items-center space-x-1">
{getRoleIcon(invitedRole)}
<span>{getRoleText(invitedRole)}</span>
</div>
</Badge>
</div>
<div className="mt-3 pt-3 border-t border-blue-200">
<p className="text-xs text-blue-600">{getRoleDescription(invitedRole)}</p>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Error/Success Messages */}
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="請輸入您的姓名"
className="pl-10"
disabled={isSubmitting}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email"> *</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="請輸入電子郵件"
className="pl-10"
disabled={isSubmitting || isInvitedUser}
readOnly={isInvitedUser}
/>
</div>
{isInvitedUser && <p className="text-xs text-gray-500"></p>}
</div>
<div className="space-y-2">
<Label htmlFor="department"> *</Label>
<div className="relative">
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Select
value={formData.department}
onValueChange={(value) => handleInputChange("department", value)}
disabled={isSubmitting}
>
<SelectTrigger className="pl-10">
<SelectValue placeholder="請選擇您的部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="password"> *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder="請輸入密碼(至少 6 個字符)"
className="pl-10"
disabled={isSubmitting}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"> *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
placeholder="請再次輸入密碼"
className="pl-10"
disabled={isSubmitting}
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
disabled={isSubmitting || isLoading}
>
{isSubmitting || isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"完成註冊"
)}
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
{" "}
<Button
variant="link"
className="p-0 h-auto font-normal text-blue-600 hover:text-blue-700"
onClick={() => router.push("/")}
>
</Button>
</p>
</div>
</CardContent>
</Card>
{/* Role Information */}
{!isInvitedUser && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3">
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
<User className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900"></h4>
<p className="text-sm text-blue-700"></p>
</div>
</div>
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<Code className="w-5 h-5 text-green-600 mt-0.5" />
<div>
<h4 className="font-medium text-green-900"></h4>
<p className="text-sm text-green-700"> AI </p>
</div>
</div>
<div className="flex items-start space-x-3 p-3 bg-purple-50 rounded-lg">
<Shield className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<h4 className="font-medium text-purple-900"></h4>
<p className="text-sm text-purple-700"></p>
</div>
</div>
</div>
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-xs text-gray-600">
<strong></strong>調
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
)
}

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,476 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useAuth } from "@/contexts/auth-context"
import {
LayoutDashboard,
Users,
Bot,
Trophy,
BarChart3,
Settings,
Menu,
X,
Bell,
Search,
LogOut,
User,
UserPlus,
FileText,
AlertTriangle,
Award,
Info,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface AdminLayoutProps {
children: React.ReactNode
currentPage: string
onPageChange: (page: string) => void
}
interface Notification {
id: string
type: "user_registration" | "app_submission" | "competition_update" | "system_alert" | "review_completed"
title: string
message: string
timestamp: string
read: boolean
}
interface SearchResult {
id: string
type: "user" | "app" | "competition"
title: string
subtitle: string
avatar?: string
}
const menuItems = [
{ id: "dashboard", name: "儀表板", icon: LayoutDashboard },
{ id: "users", name: "用戶管理", icon: Users },
{ id: "apps", name: "應用管理", icon: Bot },
{ id: "competitions", name: "競賽管理", icon: Trophy },
{ id: "analytics", name: "數據分析", icon: BarChart3 },
{ id: "settings", name: "系統設定", icon: Settings },
]
// Notifications data - empty for production
const mockNotifications: Notification[] = []
// Search data - empty for production
const mockSearchData: SearchResult[] = []
export function AdminLayout({ children, currentPage, onPageChange }: AdminLayoutProps) {
const { user, logout } = useAuth()
const [sidebarOpen, setSidebarOpen] = useState(true)
// Search state
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [showSearchResults, setShowSearchResults] = useState(false)
// Notification state
const [notifications, setNotifications] = useState<Notification[]>(mockNotifications)
const [showNotifications, setShowNotifications] = useState(false)
// Logout confirmation state
const [showLogoutDialog, setShowLogoutDialog] = useState(false)
// Handle search
useEffect(() => {
if (searchQuery.trim()) {
const filtered = mockSearchData.filter(
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.subtitle.toLowerCase().includes(searchQuery.toLowerCase()),
)
setSearchResults(filtered.slice(0, 8)) // Limit to 8 results
setShowSearchResults(true)
} else {
setSearchResults([])
setShowSearchResults(false)
}
}, [searchQuery])
// Get unread notification count
const unreadCount = notifications.filter((n) => !n.read).length
// Format timestamp
const formatTimestamp = (timestamp: string) => {
const now = new Date()
const time = new Date(timestamp)
const diffInMinutes = Math.floor((now.getTime() - time.getTime()) / (1000 * 60))
if (diffInMinutes < 1) return "剛剛"
if (diffInMinutes < 60) return `${diffInMinutes} 分鐘前`
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)} 小時前`
return `${Math.floor(diffInMinutes / 1440)} 天前`
}
// Get notification icon and color
const getNotificationIcon = (type: string) => {
switch (type) {
case "user_registration":
return <UserPlus className="w-4 h-4 text-blue-500" />
case "app_submission":
return <FileText className="w-4 h-4 text-green-500" />
case "competition_update":
return <Trophy className="w-4 h-4 text-purple-500" />
case "system_alert":
return <AlertTriangle className="w-4 h-4 text-orange-500" />
case "review_completed":
return <Award className="w-4 h-4 text-emerald-500" />
default:
return <Info className="w-4 h-4 text-gray-500" />
}
}
// Get search result icon
const getSearchIcon = (type: string) => {
switch (type) {
case "user":
return <User className="w-4 h-4 text-blue-500" />
case "app":
return <Bot className="w-4 h-4 text-green-500" />
case "competition":
return <Trophy className="w-4 h-4 text-purple-500" />
default:
return <Info className="w-4 h-4 text-gray-500" />
}
}
// Mark notification as read
const markAsRead = (notificationId: string) => {
setNotifications((prev) => prev.map((n) => (n.id === notificationId ? { ...n, read: true } : n)))
}
// Mark all notifications as read
const markAllAsRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
}
// Handle logout with improved UX
const handleLogout = () => {
logout()
setShowLogoutDialog(false)
// Check if this is a popup/new tab opened from main site
if (window.opener && !window.opener.closed) {
// If opened from another window, close this tab and focus parent
window.opener.focus()
window.close()
} else {
// If this is the main window or standalone, redirect to homepage
window.location.href = "/"
}
}
// Handle search result click
const handleSearchResultClick = (result: SearchResult) => {
setSearchQuery("")
setShowSearchResults(false)
// Navigate based on result type
switch (result.type) {
case "user":
onPageChange("users")
break
case "app":
onPageChange("apps")
break
case "competition":
onPageChange("competitions")
break
}
}
if (!user || user.role !== "admin") {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center space-y-4">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2"></h2>
<p className="text-gray-600 mb-4"></p>
<div className="space-x-3">
<Button onClick={() => (window.location.href = "/")} variant="outline">
</Button>
{window.opener && !window.opener.closed && (
<Button
onClick={() => {
window.opener.focus()
window.close()
}}
variant="default"
>
</Button>
)}
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Sidebar */}
<div className={`${sidebarOpen ? "w-64" : "w-16"} bg-white shadow-lg transition-all duration-300 flex flex-col`}>
{/* Logo */}
<div className="p-4 border-b">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-white" />
</div>
{sidebarOpen && (
<div>
<h1 className="font-bold text-gray-900"></h1>
<p className="text-xs text-gray-500">AI </p>
</div>
)}
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4">
<ul className="space-y-2">
{menuItems.map((item) => {
const IconComponent = item.icon
const isActive = currentPage === item.id
return (
<li key={item.id}>
<Button
variant={isActive ? "default" : "ghost"}
className={`w-full h-12 ${sidebarOpen ? "justify-start px-4" : "justify-center px-0"} ${
isActive
? "bg-gradient-to-r from-blue-600 to-purple-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => onPageChange(item.id)}
>
<div className="flex items-center justify-center w-5 h-5">
<IconComponent className="w-4 h-4" />
</div>
{sidebarOpen && <span className="ml-3 text-sm font-medium">{item.name}</span>}
</Button>
</li>
)
})}
</ul>
</nav>
{/* User Info */}
<div className="p-4 border-t">
<div className="flex items-center space-x-3">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-sm">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
{sidebarOpen && (
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{user.name}</p>
<p className="text-xs text-gray-500"></p>
</div>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Top Bar */}
<header className="bg-white shadow-sm border-b px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
</Button>
<h2 className="text-xl font-semibold text-gray-900">
{menuItems.find((item) => item.id === currentPage)?.name || "管理後台"}
</h2>
</div>
<div className="flex items-center space-x-4">
{/* Search */}
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜尋..."
className="pl-10 w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => searchQuery && setShowSearchResults(true)}
onBlur={() => setTimeout(() => setShowSearchResults(false), 200)}
/>
</div>
{/* Search Results Dropdown */}
{showSearchResults && searchResults.length > 0 && (
<Card className="absolute top-full mt-1 w-full z-50 shadow-lg">
<CardContent className="p-0">
<div className="max-h-80 overflow-y-auto">
{searchResults.map((result) => (
<div
key={result.id}
className="flex items-center space-x-3 p-3 hover:bg-gray-50 cursor-pointer border-b last:border-b-0"
onClick={() => handleSearchResultClick(result)}
>
{result.avatar ? (
<Avatar className="w-8 h-8">
<img
src={result.avatar || "/placeholder.svg"}
alt={result.title}
className="w-8 h-8 rounded-full"
/>
<AvatarFallback>{result.title[0]}</AvatarFallback>
</Avatar>
) : (
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center">
{getSearchIcon(result.type)}
</div>
)}
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{result.title}</p>
<p className="text-xs text-gray-500">{result.subtitle}</p>
</div>
<Badge variant="outline" className="text-xs">
{result.type === "user" ? "用戶" : result.type === "app" ? "應用" : "競賽"}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* No results message */}
{showSearchResults && searchResults.length === 0 && searchQuery.trim() && (
<Card className="absolute top-full mt-1 w-full z-50 shadow-lg">
<CardContent className="p-4 text-center text-gray-500 text-sm"></CardContent>
</Card>
)}
</div>
{/* Notifications */}
<DropdownMenu open={showNotifications} onOpenChange={setShowNotifications}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="relative">
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<Badge className="absolute -top-1 -right-1 w-5 h-5 p-0 flex items-center justify-center text-xs bg-red-500">
{unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="flex items-center justify-between">
<span></span>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={markAllAsRead} className="text-xs">
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-4 text-center text-gray-500">
<Bell className="w-8 h-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm"></p>
</div>
) : (
notifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className="p-0"
onClick={() => markAsRead(notification.id)}
>
<div className={`w-full p-3 ${!notification.read ? "bg-blue-50" : ""}`}>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate">{notification.title}</p>
{!notification.read && (
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 ml-2" />
)}
</div>
<p className="text-sm text-gray-600 mt-1">{notification.message}</p>
<p className="text-xs text-gray-400 mt-1">{formatTimestamp(notification.timestamp)}</p>
</div>
</div>
</div>
</DropdownMenuItem>
))
)}
</div>
{notifications.length > 0 && <DropdownMenuSeparator />}
<div className="p-2">
<Button variant="ghost" size="sm" className="w-full text-xs">
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Logout */}
<Button variant="ghost" size="sm" onClick={() => setShowLogoutDialog(true)}>
<LogOut className="w-4 h-4" />
</Button>
</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
{/* Logout Confirmation Dialog */}
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<LogOut className="w-5 h-5 text-red-500" />
<span></span>
</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-3 mt-6">
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleLogout}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Click outside to close search results */}
{showSearchResults && <div className="fixed inset-0 z-40" onClick={() => setShowSearchResults(false)} />}
</div>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import { useState } from "react"
import { AdminLayout } from "./admin-layout"
import { AdminDashboard } from "./dashboard"
import { UserManagement } from "./user-management"
import { AppManagement } from "./app-management"
import { CompetitionManagement } from "./competition-management"
import { AnalyticsDashboard } from "./analytics-dashboard"
import { SystemSettings } from "./system-settings"
export function AdminPanel() {
const [currentPage, setCurrentPage] = useState("dashboard")
const renderPage = () => {
switch (currentPage) {
case "dashboard":
return <AdminDashboard />
case "users":
return <UserManagement />
case "apps":
return <AppManagement />
case "competitions":
return <CompetitionManagement />
case "analytics":
return <AnalyticsDashboard />
case "settings":
return <SystemSettings />
default:
return <AdminDashboard />
}
}
return (
<AdminLayout currentPage={currentPage} onPageChange={setCurrentPage}>
{renderPage()}
</AdminLayout>
)
}

View File

@@ -0,0 +1,663 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Line,
ComposedChart,
} from "recharts"
import { Users, Eye, Star, TrendingUp, Clock, Activity, Calendar, AlertTriangle } from "lucide-react"
import { useState } from "react"
export function AnalyticsDashboard() {
const [showHistoryModal, setShowHistoryModal] = useState(false)
const [selectedDateRange, setSelectedDateRange] = useState("近7天")
// 24小時使用數據 - 優化版本
const hourlyData = [
{ hour: "00", users: 39, period: "深夜", intensity: "low", cpuUsage: 25, memoryUsage: 45 },
{ hour: "01", users: 62, period: "深夜", intensity: "normal", cpuUsage: 22, memoryUsage: 43 },
{ hour: "02", users: 24, period: "深夜", intensity: "low", cpuUsage: 20, memoryUsage: 41 },
{ hour: "03", users: 40, period: "深夜", intensity: "low", cpuUsage: 18, memoryUsage: 40 },
{ hour: "04", users: 40, period: "深夜", intensity: "low", cpuUsage: 17, memoryUsage: 39 },
{ hour: "05", users: 55, period: "清晨", intensity: "normal", cpuUsage: 19, memoryUsage: 41 },
{ hour: "06", users: 26, period: "清晨", intensity: "low", cpuUsage: 28, memoryUsage: 48 },
{ hour: "07", users: 67, period: "清晨", intensity: "normal", cpuUsage: 35, memoryUsage: 52 },
{ hour: "08", users: 26, period: "工作時間", intensity: "normal", cpuUsage: 42, memoryUsage: 58 },
{ hour: "09", users: 89, period: "工作時間", intensity: "high", cpuUsage: 58, memoryUsage: 68 },
{ hour: "10", users: 88, period: "工作時間", intensity: "high", cpuUsage: 65, memoryUsage: 72 },
{ hour: "11", users: 129, period: "工作時間", intensity: "peak", cpuUsage: 72, memoryUsage: 76 },
{ hour: "12", users: 106, period: "工作時間", intensity: "peak", cpuUsage: 62, memoryUsage: 70 },
{ hour: "13", users: 105, period: "工作時間", intensity: "peak", cpuUsage: 68, memoryUsage: 74 },
{ hour: "14", users: 81, period: "工作時間", intensity: "high", cpuUsage: 78, memoryUsage: 82 },
{ hour: "15", users: 119, period: "工作時間", intensity: "peak", cpuUsage: 74, memoryUsage: 79 },
{ hour: "16", users: 126, period: "工作時間", intensity: "peak", cpuUsage: 67, memoryUsage: 73 },
{ hour: "17", users: 112, period: "工作時間", intensity: "peak", cpuUsage: 59, memoryUsage: 67 },
{ hour: "18", users: 22, period: "晚間", intensity: "low", cpuUsage: 45, memoryUsage: 58 },
{ hour: "19", users: 60, period: "晚間", intensity: "normal", cpuUsage: 38, memoryUsage: 53 },
{ hour: "20", users: 32, period: "晚間", intensity: "low", cpuUsage: 33, memoryUsage: 50 },
{ hour: "21", users: 22, period: "晚間", intensity: "low", cpuUsage: 29, memoryUsage: 47 },
{ hour: "22", users: 36, period: "晚間", intensity: "low", cpuUsage: 26, memoryUsage: 46 },
{ hour: "23", users: 66, period: "晚間", intensity: "normal", cpuUsage: 24, memoryUsage: 44 },
]
// 獲取顏色基於使用強度
const getBarColor = (intensity: string) => {
switch (intensity) {
case "peak":
return "#ef4444" // 紅色 - 高峰期
case "high":
return "#3b82f6" // 藍色 - 高使用期
case "normal":
return "#6b7280" // 灰藍色 - 正常期
case "low":
return "#9ca3af" // 灰色 - 低峰期
default:
return "#6b7280"
}
}
// 近7天使用趨勢數據動態日期
const getRecentDates = () => {
const dates = []
const today = new Date()
for (let i = 6; i >= 0; i--) {
const date = new Date(today)
date.setDate(today.getDate() - i)
dates.push({
date: `${date.getMonth() + 1}/${date.getDate()}`,
fullDate: date.toLocaleDateString("zh-TW"),
dayName: ["日", "一", "二", "三", "四", "五", "六"][date.getDay()],
})
}
return dates
}
const recentDates = getRecentDates()
const dailyUsageData = [
{ ...recentDates[0], users: 245, sessions: 189, cpuPeak: 65, avgCpu: 45, memoryPeak: 58, requests: 1240 },
{ ...recentDates[1], users: 267, sessions: 203, cpuPeak: 68, avgCpu: 48, memoryPeak: 62, requests: 1356 },
{ ...recentDates[2], users: 289, sessions: 221, cpuPeak: 72, avgCpu: 52, memoryPeak: 65, requests: 1478 },
{ ...recentDates[3], users: 312, sessions: 245, cpuPeak: 75, avgCpu: 55, memoryPeak: 68, requests: 1589 },
{ ...recentDates[4], users: 298, sessions: 234, cpuPeak: 73, avgCpu: 53, memoryPeak: 66, requests: 1523 },
{ ...recentDates[5], users: 334, sessions: 267, cpuPeak: 78, avgCpu: 58, memoryPeak: 71, requests: 1678 },
{ ...recentDates[6], users: 356, sessions: 289, cpuPeak: 82, avgCpu: 62, memoryPeak: 75, requests: 1789 },
]
const categoryData = [
{ name: "AI工具", value: 35, color: "#3b82f6", users: 3083, apps: 45 },
{ name: "數據分析", value: 25, color: "#ef4444", users: 1565, apps: 32 },
{ name: "自動化", value: 20, color: "#10b981", users: 856, apps: 25 },
{ name: "機器學習", value: 15, color: "#f59e0b", users: 743, apps: 19 },
{ name: "其他", value: 5, color: "#8b5cf6", users: 234, apps: 6 },
]
const topApps = [
{ name: "智能客服助手", views: 1234, rating: 4.8, category: "AI工具" },
{ name: "數據視覺化平台", views: 987, rating: 4.6, category: "數據分析" },
{ name: "自動化工作流", views: 856, rating: 4.7, category: "自動化" },
{ name: "預測分析系統", views: 743, rating: 4.5, category: "機器學習" },
{ name: "文本分析工具", views: 692, rating: 4.4, category: "AI工具" },
]
// 獲取歷史數據
const getHistoricalData = (range: string) => {
const baseData = [
{ date: "12/1", users: 180, cpuPeak: 55, fullDate: "2024/12/1" },
{ date: "12/8", users: 210, cpuPeak: 62, fullDate: "2024/12/8" },
{ date: "12/15", users: 245, cpuPeak: 68, fullDate: "2024/12/15" },
{ date: "12/22", users: 280, cpuPeak: 74, fullDate: "2024/12/22" },
{ date: "12/29", users: 320, cpuPeak: 78, fullDate: "2024/12/29" },
{ date: "1/5", users: 298, cpuPeak: 73, fullDate: "2025/1/5" },
{ date: "1/12", users: 334, cpuPeak: 79, fullDate: "2025/1/12" },
{ date: "1/19", users: 356, cpuPeak: 82, fullDate: "2025/1/19" },
]
switch (range) {
case "近7天":
return dailyUsageData
case "近30天":
return baseData.slice(-4)
case "近3個月":
return baseData.slice(-6)
case "近6個月":
return baseData
default:
return dailyUsageData
}
}
// 獲取歷史統計數據
const getHistoricalStats = (range: string) => {
const data = getHistoricalData(range)
const users = data.map((d) => d.users)
const cpus = data.map((d) => d.cpuPeak)
return {
avgUsers: Math.round(users.reduce((a, b) => a + b, 0) / users.length),
maxUsers: Math.max(...users),
avgCpu: Math.round(cpus.reduce((a, b) => a + b, 0) / cpus.length),
maxCpu: Math.max(...cpus),
}
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"></h1>
<Badge variant="outline" className="text-sm">
<Activity className="w-4 h-4 mr-1" />
</Badge>
</div>
{/* 關鍵指標卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2,847</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+12.5%</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">356</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+8.2%</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Star className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">4.6</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+0.3</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">127</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+5</span>
</p>
</CardContent>
</Card>
</div>
{/* 圖表區域 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 近7天使用趨勢與系統負載 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
7使
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowHistoryModal(true)}>
<Calendar className="w-4 h-4 mr-1" />
</Button>
</div>
<p className="text-sm text-muted-foreground">CPU使用率關聯分析</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={dailyUsageData}>
<defs>
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
axisLine={{ stroke: "#e5e7eb" }}
tickLine={{ stroke: "#e5e7eb" }}
/>
<YAxis
yAxisId="users"
orientation="left"
tick={{ fontSize: 12 }}
axisLine={{ stroke: "#e5e7eb" }}
tickLine={{ stroke: "#e5e7eb" }}
domain={[200, 400]}
/>
<YAxis
yAxisId="cpu"
orientation="right"
tick={{ fontSize: 12 }}
axisLine={{ stroke: "#e5e7eb" }}
tickLine={{ stroke: "#e5e7eb" }}
domain={[40, 90]}
/>
<Tooltip
formatter={(value, name, props) => {
if (name === "users") {
return [`${value}`, "活躍用戶"]
}
if (name === "cpuPeak") {
return [`${value}%`, "CPU峰值"]
}
return [value, name]
}}
labelFormatter={(label, payload) => {
if (payload && payload.length > 0) {
const data = payload[0].payload
return `${data.fullDate} (週${data.dayName})`
}
return label
}}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
fontSize: "14px",
}}
/>
<Bar yAxisId="users" dataKey="users" fill="url(#colorUsers)" radius={[4, 4, 0, 0]} opacity={0.7} />
<Line
yAxisId="cpu"
type="monotone"
dataKey="cpuPeak"
stroke="#ef4444"
strokeWidth={3}
dot={{ fill: "#ef4444", strokeWidth: 2, r: 5 }}
activeDot={{ r: 7, stroke: "#ef4444", strokeWidth: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
{/* 系統建議 */}
<div className="mt-4 p-3 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-orange-800"></p>
<p className="text-sm text-orange-700 mt-1">
7CPU峰值達82%350
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 應用類別分布 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
outerRadius={90}
innerRadius={40}
fill="#8884d8"
dataKey="value"
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
labelLine={false}
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} stroke="#ffffff" strokeWidth={2} />
))}
</Pie>
<Tooltip
formatter={(value, name, props) => {
const data = props.payload
return [
[`${value}%`, "占比"],
[`${data.users?.toLocaleString()}`, "用戶數"],
[`${data.apps}`, "應用數量"],
]
}}
labelFormatter={(label) => label}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
fontSize: "14px",
}}
/>
</PieChart>
</ResponsiveContainer>
{/* 添加圖例說明 */}
<div className="mt-4 grid grid-cols-2 gap-2 text-sm">
{categoryData.map((category, index) => (
<div key={index} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: category.color }} />
<span className="text-gray-700">{category.name}</span>
<span className="font-medium text-gray-900">{category.value}%</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* 24小時使用模式 - 優化版 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
24使
</CardTitle>
<p className="text-sm text-muted-foreground"></p>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap gap-2">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
<div className="w-3 h-3 bg-red-500 rounded mr-2"></div>
(80%+)
</Badge>
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
<div className="w-3 h-3 bg-blue-500 rounded mr-2"></div>
使
</Badge>
<Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200">
<div className="w-3 h-3 bg-gray-500 rounded mr-2"></div>
</Badge>
<Badge variant="outline" className="bg-gray-50 text-gray-600 border-gray-300">
<div className="w-3 h-3 bg-gray-400 rounded mr-2"></div>
</Badge>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={hourlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="hour" tick={{ fontSize: 12 }} tickFormatter={(value) => `${value}:00`} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value, name, props) => {
const data = props.payload
const getIntensityText = (intensity: string) => {
switch (intensity) {
case "peak":
return "高峰期"
case "high":
return "高使用期"
case "normal":
return "正常期"
case "low":
return "低峰期"
default:
return "未知"
}
}
return [
[`${value}`, "同時在線用戶"],
[`${getIntensityText(data.intensity)}`, "時段分類"],
[`${data.cpuUsage}%`, "CPU使用率"],
[`${data.memoryUsage}%`, "記憶體使用率"],
[`${data.period}`, "時段特性"],
]
}}
labelFormatter={(label) => `${label}:00 時段`}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
fontSize: "14px",
minWidth: "220px",
}}
/>
<Bar dataKey="users" radius={[4, 4, 0, 0]} fill={(entry: any) => getBarColor(entry.intensity)}>
{hourlyData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.intensity)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800 flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
<strong></strong> 09:00-17:00 使
</p>
</div>
</CardContent>
</Card>
{/* 熱門應用排行 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{topApps.map((app, index) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center font-bold text-blue-600">
{index + 1}
</div>
<div>
<h3 className="font-medium">{app.name}</h3>
<p className="text-sm text-muted-foreground">{app.category}</p>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2">
<Eye className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">{app.views}</span>
</div>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-current" />
<span className="text-sm">{app.rating}</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 用戶回饋摘要 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 border rounded-lg">
<div className="text-2xl font-bold text-green-600">92%</div>
<p className="text-sm text-muted-foreground">滿</p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="text-2xl font-bold text-blue-600">4.6</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="text-2xl font-bold text-purple-600">156</div>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</CardContent>
</Card>
{/* 歷史數據查看模態框 */}
{showHistoryModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-4xl max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold"></h2>
<Button variant="ghost" size="sm" onClick={() => setShowHistoryModal(false)}>
</Button>
</div>
{/* 日期範圍選擇 */}
<div className="mb-6">
<div className="flex gap-2 mb-4">
{["近7天", "近30天", "近3個月", "近6個月"].map((range) => (
<Button
key={range}
variant={selectedDateRange === range ? "default" : "outline"}
size="sm"
onClick={() => setSelectedDateRange(range)}
>
{range}
</Button>
))}
</div>
</div>
{/* 歷史數據圖表 */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>使 - {selectedDateRange}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ComposedChart data={getHistoricalData(selectedDateRange)}>
<defs>
<linearGradient id="colorUsersHistory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis yAxisId="users" orientation="left" tick={{ fontSize: 12 }} />
<YAxis yAxisId="cpu" orientation="right" tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value, name, props) => {
if (name === "users") {
return [`${value}`, "活躍用戶"]
}
if (name === "cpuPeak") {
return [`${value}%`, "CPU峰值"]
}
return [value, name]
}}
labelFormatter={(label, payload) => {
if (payload && payload.length > 0) {
const data = payload[0].payload
return data.fullDate || label
}
return label
}}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "8px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
fontSize: "14px",
}}
/>
<Bar
yAxisId="users"
dataKey="users"
fill="url(#colorUsersHistory)"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Line
yAxisId="cpu"
type="monotone"
dataKey="cpuPeak"
stroke="#ef4444"
strokeWidth={2}
dot={{ fill: "#ef4444", strokeWidth: 1, r: 3 }}
/>
</ComposedChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* 歷史數據統計摘要 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{getHistoricalStats(selectedDateRange).avgUsers}
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{getHistoricalStats(selectedDateRange).maxUsers}
</div>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{getHistoricalStats(selectedDateRange).avgCpu}%
</div>
<p className="text-sm text-muted-foreground">CPU使用率</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{getHistoricalStats(selectedDateRange).maxCpu}%
</div>
<p className="text-sm text-muted-foreground">CPU使用率</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,991 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import {
Search,
Plus,
MoreHorizontal,
Edit,
Trash2,
Eye,
Star,
Heart,
TrendingUp,
Bot,
CheckCircle,
Clock,
MessageSquare,
ExternalLink,
AlertTriangle,
X,
Check,
TrendingDown,
Link,
Zap,
Brain,
Mic,
ImageIcon,
FileText,
BarChart3,
Camera,
Music,
Video,
Code,
Database,
Globe,
Smartphone,
Monitor,
Headphones,
Palette,
Calculator,
Shield,
Settings,
Lightbulb,
} from "lucide-react"
// Add available icons array after imports
const availableIcons = [
{ name: "Bot", icon: Bot, color: "from-blue-500 to-purple-500" },
{ name: "Brain", icon: Brain, color: "from-purple-500 to-pink-500" },
{ name: "Zap", icon: Zap, color: "from-yellow-500 to-orange-500" },
{ name: "Mic", icon: Mic, color: "from-green-500 to-teal-500" },
{ name: "ImageIcon", icon: ImageIcon, color: "from-pink-500 to-rose-500" },
{ name: "FileText", icon: FileText, color: "from-blue-500 to-cyan-500" },
{ name: "BarChart3", icon: BarChart3, color: "from-emerald-500 to-green-500" },
{ name: "Camera", icon: Camera, color: "from-indigo-500 to-purple-500" },
{ name: "Music", icon: Music, color: "from-violet-500 to-purple-500" },
{ name: "Video", icon: Video, color: "from-red-500 to-pink-500" },
{ name: "Code", icon: Code, color: "from-gray-500 to-slate-500" },
{ name: "Database", icon: Database, color: "from-cyan-500 to-blue-500" },
{ name: "Globe", icon: Globe, color: "from-blue-500 to-indigo-500" },
{ name: "Smartphone", icon: Smartphone, color: "from-slate-500 to-gray-500" },
{ name: "Monitor", icon: Monitor, color: "from-gray-600 to-slate-600" },
{ name: "Headphones", icon: Headphones, color: "from-purple-500 to-violet-500" },
{ name: "Palette", icon: Palette, color: "from-pink-500 to-purple-500" },
{ name: "Calculator", icon: Calculator, color: "from-orange-500 to-red-500" },
{ name: "Shield", icon: Shield, color: "from-green-500 to-emerald-500" },
{ name: "Settings", icon: Settings, color: "from-gray-500 to-zinc-500" },
{ name: "Lightbulb", icon: Lightbulb, color: "from-yellow-500 to-amber-500" },
]
// App data - empty for production
const mockApps: any[] = []
export function AppManagement() {
const [apps, setApps] = useState(mockApps)
const [searchTerm, setSearchTerm] = useState("")
const [selectedType, setSelectedType] = useState("all")
const [selectedStatus, setSelectedStatus] = useState("all")
const [selectedApp, setSelectedApp] = useState<any>(null)
const [showAppDetail, setShowAppDetail] = useState(false)
const [showAddApp, setShowAddApp] = useState(false)
const [showEditApp, setShowEditApp] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showApprovalDialog, setShowApprovalDialog] = useState(false)
const [approvalAction, setApprovalAction] = useState<"approve" | "reject">("approve")
const [approvalReason, setApprovalReason] = useState("")
const [newApp, setNewApp] = useState({
name: "",
type: "文字處理",
department: "HQBU",
creator: "",
description: "",
appUrl: "",
icon: "Bot",
iconColor: "from-blue-500 to-purple-500",
})
const filteredApps = apps.filter((app) => {
const matchesSearch =
app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.creator.toLowerCase().includes(searchTerm.toLowerCase())
const matchesType = selectedType === "all" || app.type === selectedType
const matchesStatus = selectedStatus === "all" || app.status === selectedStatus
return matchesSearch && matchesType && matchesStatus
})
const handleViewApp = (app: any) => {
setSelectedApp(app)
setShowAppDetail(true)
}
const handleEditApp = (app: any) => {
setSelectedApp(app)
setNewApp({
name: app.name,
type: app.type,
department: app.department,
creator: app.creator,
description: app.description,
appUrl: app.appUrl,
icon: app.icon || "Bot",
iconColor: app.iconColor || "from-blue-500 to-purple-500",
})
setShowEditApp(true)
}
const handleDeleteApp = (app: any) => {
setSelectedApp(app)
setShowDeleteConfirm(true)
}
const confirmDeleteApp = () => {
if (selectedApp) {
setApps(apps.filter((app) => app.id !== selectedApp.id))
setShowDeleteConfirm(false)
setSelectedApp(null)
}
}
const handleToggleAppStatus = (appId: string) => {
setApps(
apps.map((app) =>
app.id === appId
? {
...app,
status: app.status === "published" ? "draft" : "published",
}
: app,
),
)
}
const handleApprovalAction = (app: any, action: "approve" | "reject") => {
setSelectedApp(app)
setApprovalAction(action)
setApprovalReason("")
setShowApprovalDialog(true)
}
const confirmApproval = () => {
if (selectedApp) {
setApps(
apps.map((app) =>
app.id === selectedApp.id
? {
...app,
status: approvalAction === "approve" ? "published" : "rejected",
}
: app,
),
)
setShowApprovalDialog(false)
setSelectedApp(null)
setApprovalReason("")
}
}
const handleAddApp = () => {
const app = {
id: Date.now().toString(),
...newApp,
status: "pending",
createdAt: new Date().toISOString().split("T")[0],
views: 0,
likes: 0,
rating: 0,
reviews: 0,
}
setApps([...apps, app])
setNewApp({
name: "",
type: "文字處理",
department: "HQBU",
creator: "",
description: "",
appUrl: "",
icon: "Bot",
iconColor: "from-blue-500 to-purple-500",
})
setShowAddApp(false)
}
const handleUpdateApp = () => {
if (selectedApp) {
setApps(
apps.map((app) =>
app.id === selectedApp.id
? {
...app,
...newApp,
}
: app,
),
)
setShowEditApp(false)
setSelectedApp(null)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "published":
return "bg-green-100 text-green-800 border-green-200"
case "pending":
return "bg-yellow-100 text-yellow-800 border-yellow-200"
case "draft":
return "bg-gray-100 text-gray-800 border-gray-200"
case "rejected":
return "bg-red-100 text-red-800 border-red-200"
default:
return "bg-gray-100 text-gray-800 border-gray-200"
}
}
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const getStatusText = (status: string) => {
switch (status) {
case "published":
return "已發布"
case "pending":
return "待審核"
case "draft":
return "草稿"
case "rejected":
return "已拒絕"
default:
return status
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600"> AI </p>
</div>
<Button
onClick={() => setShowAddApp(true)}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{apps.length}</p>
</div>
<Bot className="w-8 h-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{apps.filter((a) => a.status === "published").length}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{apps.filter((a) => a.status === "pending").length}</p>
</div>
<Clock className="w-8 h-8 text-yellow-600" />
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col lg:flex-row gap-4 items-center">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜尋應用名稱或創建者..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
</SelectContent>
</Select>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-32">
<SelectValue placeholder="狀態" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="published"></SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="rejected"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Apps Table */}
<Card>
<CardHeader>
<CardTitle> ({filteredApps.length})</CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApps.map((app) => (
<TableRow key={app.id}>
<TableCell>
<div className="flex items-center space-x-3">
<div
className={`w-8 h-8 bg-gradient-to-r ${app.iconColor} rounded-lg flex items-center justify-center`}
>
{(() => {
const IconComponent = availableIcons.find((icon) => icon.name === app.icon)?.icon || Bot
return <IconComponent className="w-4 h-4 text-white" />
})()}
</div>
<div>
<div className="flex items-center space-x-2">
<p className="font-medium">{app.name}</p>
{app.appUrl && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => window.open(app.appUrl, "_blank")}
title="開啟應用"
>
<ExternalLink className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={getTypeColor(app.type)}>
{app.type}
</Badge>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{app.creator}</p>
<p className="text-sm text-gray-500">{app.department}</p>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={getStatusColor(app.status)}>
{getStatusText(app.status)}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{app.views}</span>
</div>
<div className="flex items-center space-x-1">
<Heart className="w-3 h-3" />
<span>{app.likes}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span className="font-medium">{app.rating}</span>
<span className="text-sm text-gray-500">({app.reviews})</span>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">{app.createdAt}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewApp(app)}>
<Eye className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditApp(app)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
{app.appUrl && (
<DropdownMenuItem onClick={() => window.open(app.appUrl, "_blank")}>
<ExternalLink className="w-4 h-4 mr-2" />
</DropdownMenuItem>
)}
{app.status === "pending" && (
<>
<DropdownMenuItem onClick={() => handleApprovalAction(app, "approve")}>
<Check className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleApprovalAction(app, "reject")}>
<X className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</>
)}
{app.status !== "pending" && (
<DropdownMenuItem onClick={() => handleToggleAppStatus(app.id)}>
{app.status === "published" ? (
<>
<TrendingDown className="w-4 h-4 mr-2" />
</>
) : (
<>
<TrendingUp className="w-4 h-4 mr-2" />
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuItem className="text-red-600" onClick={() => handleDeleteApp(app)}>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add App Dialog */}
<Dialog open={showAddApp} onOpenChange={setShowAddApp}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> AI </DialogTitle>
<DialogDescription> AI </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={newApp.name}
onChange={(e) => setNewApp({ ...newApp, name: e.target.value })}
placeholder="輸入應用名稱"
/>
</div>
<div className="space-y-2">
<Label htmlFor="creator"> *</Label>
<Input
id="creator"
value={newApp.creator}
onChange={(e) => setNewApp({ ...newApp, creator: e.target.value })}
placeholder="輸入創建者姓名"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type"></Label>
<Select value={newApp.type} onValueChange={(value) => setNewApp({ ...newApp, type: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Select
value={newApp.department}
onValueChange={(value) => setNewApp({ ...newApp, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="icon"></Label>
<div className="grid grid-cols-7 gap-2 p-4 border rounded-lg max-h-60 overflow-y-auto bg-gray-50">
{availableIcons.map((iconOption) => {
const IconComponent = iconOption.icon
return (
<button
key={iconOption.name}
type="button"
onClick={() => {
setNewApp({
...newApp,
icon: iconOption.name,
iconColor: iconOption.color,
})
}}
className={`w-12 h-12 rounded-lg flex items-center justify-center transition-all hover:scale-105 ${
newApp.icon === iconOption.name
? `bg-gradient-to-r ${iconOption.color} shadow-lg ring-2 ring-blue-500`
: `bg-gradient-to-r ${iconOption.color} opacity-70 hover:opacity-100`
}`}
title={iconOption.name}
>
<IconComponent className="w-6 h-6 text-white" />
</button>
)
})}
</div>
<p className="text-xs text-gray-500"></p>
</div>
<div className="space-y-2">
<Label htmlFor="appUrl"></Label>
<div className="relative">
<Link className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="appUrl"
value={newApp.appUrl}
onChange={(e) => setNewApp({ ...newApp, appUrl: e.target.value })}
placeholder="https://your-app.example.com"
className="pl-10"
/>
</div>
<p className="text-xs text-gray-500"></p>
</div>
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
value={newApp.description}
onChange={(e) => setNewApp({ ...newApp, description: e.target.value })}
placeholder="描述應用的功能和特色"
rows={3}
/>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowAddApp(false)}>
</Button>
<Button onClick={handleAddApp} disabled={!newApp.name || !newApp.creator || !newApp.description}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Edit App Dialog */}
<Dialog open={showEditApp} onOpenChange={setShowEditApp}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-name"> *</Label>
<Input
id="edit-name"
value={newApp.name}
onChange={(e) => setNewApp({ ...newApp, name: e.target.value })}
placeholder="輸入應用名稱"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-creator"> *</Label>
<Input
id="edit-creator"
value={newApp.creator}
onChange={(e) => setNewApp({ ...newApp, creator: e.target.value })}
placeholder="輸入創建者姓名"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-type"></Label>
<Select value={newApp.type} onValueChange={(value) => setNewApp({ ...newApp, type: value })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-department"></Label>
<Select
value={newApp.department}
onValueChange={(value) => setNewApp({ ...newApp, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="icon"></Label>
<div className="grid grid-cols-7 gap-2 p-4 border rounded-lg max-h-60 overflow-y-auto bg-gray-50">
{availableIcons.map((iconOption) => {
const IconComponent = iconOption.icon
return (
<button
key={iconOption.name}
type="button"
onClick={() => {
setNewApp({
...newApp,
icon: iconOption.name,
iconColor: iconOption.color,
})
}}
className={`w-12 h-12 rounded-lg flex items-center justify-center transition-all hover:scale-105 ${
newApp.icon === iconOption.name
? `bg-gradient-to-r ${iconOption.color} shadow-lg ring-2 ring-blue-500`
: `bg-gradient-to-r ${iconOption.color} opacity-70 hover:opacity-100`
}`}
title={iconOption.name}
>
<IconComponent className="w-6 h-6 text-white" />
</button>
)
})}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-appUrl"></Label>
<div className="relative">
<Link className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="edit-appUrl"
value={newApp.appUrl}
onChange={(e) => setNewApp({ ...newApp, appUrl: e.target.value })}
placeholder="https://your-app.example.com"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description"> *</Label>
<Textarea
id="edit-description"
value={newApp.description}
onChange={(e) => setNewApp({ ...newApp, description: e.target.value })}
placeholder="描述應用的功能和特色"
rows={3}
/>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowEditApp(false)}>
</Button>
<Button onClick={handleUpdateApp} disabled={!newApp.name || !newApp.creator || !newApp.description}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
<span></span>
</DialogTitle>
<DialogDescription>{selectedApp?.name}</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
</Button>
<Button variant="destructive" onClick={confirmDeleteApp}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Approval Dialog */}
<Dialog open={showApprovalDialog} onOpenChange={setShowApprovalDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
{approvalAction === "approve" ? (
<CheckCircle className="w-5 h-5 text-green-500" />
) : (
<X className="w-5 h-5 text-red-500" />
)}
<span>{approvalAction === "approve" ? "批准應用" : "拒絕應用"}</span>
</DialogTitle>
<DialogDescription>
{approvalAction === "approve"
? `確認批准應用「${selectedApp?.name}」並發布到平台?`
: `確認拒絕應用「${selectedApp?.name}」的發布申請?`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="approval-reason">
{approvalAction === "approve" ? "批准備註(可選)" : "拒絕原因 *"}
</Label>
<Textarea
id="approval-reason"
value={approvalReason}
onChange={(e) => setApprovalReason(e.target.value)}
placeholder={approvalAction === "approve" ? "輸入批准備註..." : "請說明拒絕原因,以便開發者了解並改進"}
rows={3}
/>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowApprovalDialog(false)}>
</Button>
<Button
onClick={confirmApproval}
variant={approvalAction === "approve" ? "default" : "destructive"}
disabled={approvalAction === "reject" && !approvalReason.trim()}
>
{approvalAction === "approve" ? "確認批准" : "確認拒絕"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* App Detail Dialog */}
<Dialog open={showAppDetail} onOpenChange={setShowAppDetail}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedApp && (
<Tabs defaultValue="info" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="info"></TabsTrigger>
<TabsTrigger value="stats"></TabsTrigger>
<TabsTrigger value="reviews"></TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-4">
<div className="flex items-start space-x-4">
<div
className={`w-16 h-16 bg-gradient-to-r ${selectedApp.iconColor} rounded-xl flex items-center justify-center`}
>
{(() => {
const IconComponent = availableIcons.find((icon) => icon.name === selectedApp.icon)?.icon || Bot
return <IconComponent className="w-8 h-8 text-white" />
})()}
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h3 className="text-xl font-semibold">{selectedApp.name}</h3>
{selectedApp.appUrl && (
<Button variant="outline" size="sm" onClick={() => window.open(selectedApp.appUrl, "_blank")}>
<ExternalLink className="w-4 h-4 mr-2" />
</Button>
)}
</div>
<p className="text-gray-600 mb-2">{selectedApp.description}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className={getTypeColor(selectedApp.type)}>
{selectedApp.type}
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700">
{selectedApp.department}
</Badge>
<Badge variant="outline" className={getStatusColor(selectedApp.status)}>
{getStatusText(selectedApp.status)}
</Badge>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{selectedApp.creator}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{selectedApp.createdAt}</p>
</div>
<div>
<p className="text-sm text-gray-500">ID</p>
<p className="font-medium">{selectedApp.id}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{selectedApp.department}</p>
</div>
</div>
{selectedApp.appUrl && (
<div>
<p className="text-sm text-gray-500"></p>
<div className="flex items-center space-x-2">
<p className="font-medium text-blue-600">{selectedApp.appUrl}</p>
<Button
variant="ghost"
size="sm"
onClick={() => navigator.clipboard.writeText(selectedApp.appUrl)}
>
</Button>
</div>
</div>
)}
</TabsContent>
<TabsContent value="stats" className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{selectedApp.views}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{selectedApp.likes}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-yellow-600">{selectedApp.rating}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{selectedApp.reviews}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="reviews" className="space-y-4">
<div className="text-center py-8">
<MessageSquare className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500"></p>
</div>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
"use client"
import { useCompetition } from "@/contexts/competition-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Users, Bot, Trophy, TrendingUp, Eye, Heart, MessageSquare, Award, Activity } from "lucide-react"
// Dashboard data - empty for production
const mockStats = {
totalUsers: 0,
activeUsers: 0,
totalApps: 0,
totalCompetitions: 0,
totalReviews: 0,
totalViews: 0,
totalLikes: 0,
}
const recentActivities: any[] = []
const topApps: any[] = []
export function AdminDashboard() {
const { competitions } = useCompetition()
return (
<div className="space-y-6">
{/* Welcome Section */}
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600"> AI </p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Users className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{mockStats.totalUsers}</div>
<p className="text-xs text-muted-foreground"> {mockStats.activeUsers} </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">AI </CardTitle>
<Bot className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{mockStats.totalApps}</div>
<p className="text-xs text-muted-foreground"> 2 </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Trophy className="h-4 w-4 text-purple-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{competitions.length}</div>
<p className="text-xs text-muted-foreground">1 </p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{mockStats.totalViews.toLocaleString()}</div>
<p className="text-xs text-muted-foreground"> 12%</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activities */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Activity className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivities.map((activity) => {
const IconComponent = activity.icon
return (
<div key={activity.id} className="flex items-center space-x-3">
<div className={`p-2 rounded-full bg-gray-100 ${activity.color}`}>
<IconComponent className="w-4 h-4" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">{activity.message}</p>
<p className="text-xs text-gray-500">{activity.time}</p>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Top Performing Apps */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Award className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{topApps.map((app, index) => (
<div key={index} className="flex items-center justify-between">
<div>
<p className="font-medium">{app.name}</p>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{app.views}</span>
</div>
<div className="flex items-center space-x-1">
<Heart className="w-3 h-3" />
<span>{app.likes}</span>
</div>
</div>
</div>
<Badge variant="secondary">{app.rating} </Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Button className="h-20 flex flex-col space-y-2">
<Users className="w-6 h-6" />
<span></span>
</Button>
<Button className="h-20 flex flex-col space-y-2 bg-transparent" variant="outline">
<Bot className="w-6 h-6" />
<span></span>
</Button>
<Button className="h-20 flex flex-col space-y-2 bg-transparent" variant="outline">
<Trophy className="w-6 h-6" />
<span></span>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Copy, Users } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
interface Judge {
id: string
name: string
specialty: string
}
interface JudgeListDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
judges: Judge[]
}
export function JudgeListDialog({ open, onOpenChange, judges }: JudgeListDialogProps) {
const { toast } = useToast()
const handleCopyJudgeId = async (judgeId: string, judgeName: string) => {
try {
await navigator.clipboard.writeText(judgeId)
toast({
title: "ID已複製",
description: `${judgeName}的ID已複製到剪貼簿`,
})
} catch (err) {
toast({
title: "複製失敗",
description: "無法複製ID請手動複製",
variant: "destructive",
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Users className="w-5 h-5" />
<span></span>
</DialogTitle>
<DialogDescription>
ID和基本資訊
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{judges.map((judge) => (
<Card key={judge.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-center justify-between">
{/* 左側:頭像和資訊 */}
<div className="flex items-center space-x-4">
<Avatar className="w-12 h-12">
<AvatarFallback className="text-sm font-semibold bg-gray-100">
{judge.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-lg">{judge.name}</h3>
<p className="text-sm text-gray-600">{judge.specialty}</p>
</div>
</div>
{/* 右側ID和複製按鈕 */}
<div className="flex items-center space-x-3">
<div className="bg-gray-100 px-3 py-1 rounded-lg">
<span className="text-sm font-medium text-gray-700">
ID: {judge.id}
</span>
</div>
<Button
onClick={() => handleCopyJudgeId(judge.id, judge.name)}
variant="outline"
size="sm"
className="flex items-center space-x-2"
>
<Copy className="w-4 h-4" />
<span></span>
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,744 @@
"use client"
import { useState } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
Lightbulb,
Plus,
MoreHorizontal,
Edit,
Trash2,
Eye,
FileText,
Users,
Calendar,
CheckCircle,
AlertTriangle,
Loader2,
Target,
AlertCircle,
} from "lucide-react"
import type { Proposal } from "@/types/competition"
export function ProposalManagement() {
const { proposals, addProposal, updateProposal, getProposalById, teams, getTeamById } = useCompetition()
const [searchTerm, setSearchTerm] = useState("")
const [selectedTeam, setSelectedTeam] = useState("all")
const [selectedProposal, setSelectedProposal] = useState<Proposal | null>(null)
const [showProposalDetail, setShowProposalDetail] = useState(false)
const [showAddProposal, setShowAddProposal] = useState(false)
const [showEditProposal, setShowEditProposal] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState("")
const [error, setError] = useState("")
const [newProposal, setNewProposal] = useState({
title: "",
description: "",
problemStatement: "",
solution: "",
expectedImpact: "",
teamId: "",
attachments: [] as string[],
})
const filteredProposals = proposals.filter((proposal) => {
const team = getTeamById(proposal.teamId)
const matchesSearch =
proposal.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
proposal.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
team?.name.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTeam = selectedTeam === "all" || proposal.teamId === selectedTeam
return matchesSearch && matchesTeam
})
const handleViewProposal = (proposal: Proposal) => {
setSelectedProposal(proposal)
setShowProposalDetail(true)
}
const handleEditProposal = (proposal: Proposal) => {
setSelectedProposal(proposal)
setNewProposal({
title: proposal.title,
description: proposal.description,
problemStatement: proposal.problemStatement,
solution: proposal.solution,
expectedImpact: proposal.expectedImpact,
teamId: proposal.teamId,
attachments: proposal.attachments || [],
})
setShowEditProposal(true)
}
const handleDeleteProposal = (proposal: Proposal) => {
setSelectedProposal(proposal)
setShowDeleteConfirm(true)
}
const confirmDeleteProposal = () => {
if (selectedProposal) {
// In a real app, you would call a delete function here
setShowDeleteConfirm(false)
setSelectedProposal(null)
setSuccess("提案刪除成功!")
setTimeout(() => setSuccess(""), 3000)
}
}
const handleAddProposal = async () => {
setError("")
if (
!newProposal.title ||
!newProposal.description ||
!newProposal.problemStatement ||
!newProposal.solution ||
!newProposal.expectedImpact ||
!newProposal.teamId
) {
setError("請填寫所有必填欄位")
return
}
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
addProposal({
...newProposal,
submittedAt: new Date().toISOString(),
})
setShowAddProposal(false)
setNewProposal({
title: "",
description: "",
problemStatement: "",
solution: "",
expectedImpact: "",
teamId: "",
attachments: [],
})
setSuccess("提案創建成功!")
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
const handleUpdateProposal = async () => {
if (!selectedProposal) return
setError("")
if (
!newProposal.title ||
!newProposal.description ||
!newProposal.problemStatement ||
!newProposal.solution ||
!newProposal.expectedImpact ||
!newProposal.teamId
) {
setError("請填寫所有必填欄位")
return
}
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
updateProposal(selectedProposal.id, newProposal)
setShowEditProposal(false)
setSelectedProposal(null)
setSuccess("提案更新成功!")
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
return (
<div className="space-y-6">
{/* Success/Error Messages */}
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600"></p>
</div>
<Button
onClick={() => setShowAddProposal(true)}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{proposals.length}</p>
</div>
<Lightbulb className="w-8 h-8 text-purple-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{new Set(proposals.map((p) => p.teamId)).size}</p>
</div>
<Users className="w-8 h-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">
{
proposals.filter((p) => {
const submittedDate = new Date(p.submittedAt)
const now = new Date()
return (
submittedDate.getMonth() === now.getMonth() && submittedDate.getFullYear() === now.getFullYear()
)
}).length
}
</p>
</div>
<Calendar className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col lg:flex-row gap-4 items-center">
<div className="flex-1 relative">
<Input
placeholder="搜尋提案標題、描述或團隊名稱..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-3">
<Select value={selectedTeam} onValueChange={setSelectedTeam}>
<SelectTrigger className="w-40">
<SelectValue placeholder="團隊" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Proposals Table */}
<Card>
<CardHeader>
<CardTitle> ({filteredProposals.length})</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProposals.map((proposal) => {
const team = getTeamById(proposal.teamId)
const submittedDate = new Date(proposal.submittedAt)
return (
<TableRow key={proposal.id}>
<TableCell>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<Lightbulb className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-medium">{proposal.title}</p>
<p className="text-sm text-gray-500 line-clamp-1">{proposal.description}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<div className="w-6 h-6 bg-gradient-to-r from-green-500 to-blue-500 rounded flex items-center justify-center">
<Users className="w-3 h-3 text-white" />
</div>
<div>
<p className="font-medium text-sm">{team?.name || "未知團隊"}</p>
<p className="text-xs text-gray-500">{team?.department}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="max-w-xs">
<p className="text-sm line-clamp-2">{proposal.problemStatement}</p>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<p>{submittedDate.toLocaleDateString()}</p>
<p className="text-gray-500">{submittedDate.toLocaleTimeString()}</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<FileText className="w-4 h-4 text-gray-500" />
<span className="text-sm">{proposal.attachments?.length || 0}</span>
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewProposal(proposal)}>
<Eye className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditProposal(proposal)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => handleDeleteProposal(proposal)}>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Proposal Dialog */}
<Dialog open={showAddProposal} onOpenChange={setShowAddProposal}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="proposalTitle"> *</Label>
<Input
id="proposalTitle"
value={newProposal.title}
onChange={(e) => setNewProposal({ ...newProposal, title: e.target.value })}
placeholder="輸入提案標題"
/>
</div>
<div className="space-y-2">
<Label htmlFor="proposalTeam"> *</Label>
<Select
value={newProposal.teamId}
onValueChange={(value) => setNewProposal({ ...newProposal, teamId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇團隊" />
</SelectTrigger>
<SelectContent>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name} ({team.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="proposalDescription"> *</Label>
<Textarea
id="proposalDescription"
value={newProposal.description}
onChange={(e) => setNewProposal({ ...newProposal, description: e.target.value })}
placeholder="簡要描述提案的核心內容"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="problemStatement"> *</Label>
<Textarea
id="problemStatement"
value={newProposal.problemStatement}
onChange={(e) => setNewProposal({ ...newProposal, problemStatement: e.target.value })}
placeholder="詳細描述要解決的問題或痛點"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="solution"> *</Label>
<Textarea
id="solution"
value={newProposal.solution}
onChange={(e) => setNewProposal({ ...newProposal, solution: e.target.value })}
placeholder="描述您提出的解決方案"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expectedImpact"> *</Label>
<Textarea
id="expectedImpact"
value={newProposal.expectedImpact}
onChange={(e) => setNewProposal({ ...newProposal, expectedImpact: e.target.value })}
placeholder="描述預期產生的商業和社會影響"
rows={3}
/>
</div>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowAddProposal(false)}>
</Button>
<Button onClick={handleAddProposal} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"創建提案"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Edit Proposal Dialog */}
<Dialog open={showEditProposal} onOpenChange={setShowEditProposal}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="editProposalTitle"> *</Label>
<Input
id="editProposalTitle"
value={newProposal.title}
onChange={(e) => setNewProposal({ ...newProposal, title: e.target.value })}
placeholder="輸入提案標題"
/>
</div>
<div className="space-y-2">
<Label htmlFor="editProposalTeam"> *</Label>
<Select
value={newProposal.teamId}
onValueChange={(value) => setNewProposal({ ...newProposal, teamId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇團隊" />
</SelectTrigger>
<SelectContent>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name} ({team.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="editProposalDescription"> *</Label>
<Textarea
id="editProposalDescription"
value={newProposal.description}
onChange={(e) => setNewProposal({ ...newProposal, description: e.target.value })}
placeholder="簡要描述提案的核心內容"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="editProblemStatement"> *</Label>
<Textarea
id="editProblemStatement"
value={newProposal.problemStatement}
onChange={(e) => setNewProposal({ ...newProposal, problemStatement: e.target.value })}
placeholder="詳細描述要解決的問題或痛點"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="editSolution"> *</Label>
<Textarea
id="editSolution"
value={newProposal.solution}
onChange={(e) => setNewProposal({ ...newProposal, solution: e.target.value })}
placeholder="描述您提出的解決方案"
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="editExpectedImpact"> *</Label>
<Textarea
id="editExpectedImpact"
value={newProposal.expectedImpact}
onChange={(e) => setNewProposal({ ...newProposal, expectedImpact: e.target.value })}
placeholder="描述預期產生的商業和社會影響"
rows={3}
/>
</div>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowEditProposal(false)}>
</Button>
<Button onClick={handleUpdateProposal} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"更新提案"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
<span></span>
</DialogTitle>
<DialogDescription>{selectedProposal?.title}</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
</Button>
<Button variant="destructive" onClick={confirmDeleteProposal}>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Proposal Detail Dialog */}
<Dialog open={showProposalDetail} onOpenChange={setShowProposalDetail}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedProposal && (
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="details"></TabsTrigger>
<TabsTrigger value="team"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Lightbulb className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold">{selectedProposal.title}</h3>
<p className="text-gray-600 mb-2">{selectedProposal.description}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-purple-100 text-purple-700">
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700">
{getTeamById(selectedProposal.teamId)?.name || "未知團隊"}
</Badge>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">ID</p>
<p className="font-medium">{selectedProposal.id}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{new Date(selectedProposal.submittedAt).toLocaleString()}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{getTeamById(selectedProposal.teamId)?.name || "未知團隊"}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{selectedProposal.attachments?.length || 0} </p>
</div>
</div>
</TabsContent>
<TabsContent value="details" className="space-y-6">
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h5 className="font-semibold text-red-900 mb-2 flex items-center">
<AlertCircle className="w-5 h-5 mr-2" />
</h5>
<p className="text-red-800">{selectedProposal.problemStatement}</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h5 className="font-semibold text-green-900 mb-2 flex items-center">
<CheckCircle className="w-5 h-5 mr-2" />
</h5>
<p className="text-green-800">{selectedProposal.solution}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h5 className="font-semibold text-blue-900 mb-2 flex items-center">
<Target className="w-5 h-5 mr-2" />
</h5>
<p className="text-blue-800">{selectedProposal.expectedImpact}</p>
</div>
</div>
</TabsContent>
<TabsContent value="team" className="space-y-4">
{(() => {
const team = getTeamById(selectedProposal.teamId)
return team ? (
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-r from-green-500 to-blue-500 rounded-lg flex items-center justify-center">
<Users className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-semibold text-lg">{team.name}</h4>
<p className="text-gray-600">{team.department}</p>
<p className="text-sm text-gray-500">{team.contactEmail}</p>
</div>
</div>
<div>
<h5 className="font-medium mb-3"></h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{team.members.map((member) => (
<div key={member.id} className="flex items-center space-x-3 p-3 border rounded-lg">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span className="text-green-700 font-medium text-sm">{member.name[0]}</span>
</div>
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="font-medium">{member.name}</span>
{member.id === team.leader && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
</Badge>
)}
</div>
<div className="text-sm text-gray-600">
{member.department} {member.role}
</div>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<Users className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500"></p>
</div>
)
})()}
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,152 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Copy, Link } from "lucide-react"
import { useToast } from "@/hooks/use-toast"
interface ScoringLinkDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
currentCompetition?: any
}
export function ScoringLinkDialog({ open, onOpenChange, currentCompetition }: ScoringLinkDialogProps) {
const { toast } = useToast()
// 生成評分連結URL
const scoringUrl = typeof window !== 'undefined'
? `${window.location.origin}/judge-scoring`
: "https://preview-fork-of-ai-app-design-ieqe9ld0z64vdugqt.vusercontent.net/judge-scoring"
const accessCode = "judge2024"
const competitionName = currentCompetition?.name || "2024年第四季綜合AI競賽"
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(scoringUrl)
toast({
title: "連結已複製",
description: "評分系統連結已複製到剪貼簿",
})
} catch (err) {
toast({
title: "複製失敗",
description: "無法複製連結,請手動複製",
variant: "destructive",
})
}
}
const handleCopyAccessCode = async () => {
try {
await navigator.clipboard.writeText(accessCode)
toast({
title: "存取碼已複製",
description: "評審存取碼已複製到剪貼簿",
})
} catch (err) {
toast({
title: "複製失敗",
description: "無法複製存取碼,請手動複製",
variant: "destructive",
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Link className="w-5 h-5" />
<span></span>
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 評審評分系統連結 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Link className="w-5 h-5 text-blue-600" />
<span></span>
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex space-x-2">
<Input
value={scoringUrl}
readOnly
className="flex-1"
/>
<Button
onClick={handleCopyUrl}
variant="outline"
size="sm"
className="flex items-center space-x-2"
>
<Copy className="w-4 h-4" />
<span></span>
</Button>
</div>
</CardContent>
</Card>
{/* 存取資訊 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 存取碼 */}
<div className="space-y-2">
<Label></Label>
<div className="flex space-x-2">
<Input
value={accessCode}
readOnly
className="flex-1"
/>
<Button
onClick={handleCopyAccessCode}
variant="outline"
size="sm"
className="flex items-center space-x-2"
>
<Copy className="w-4 h-4" />
<span></span>
</Button>
</div>
</div>
{/* 當前競賽 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center space-x-2">
<Badge variant="outline" className="text-blue-600 border-blue-200 bg-blue-50">
{competitionName}
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,857 @@
"use client"
import { useState, useEffect } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Progress } from "@/components/ui/progress"
import { ScoringLinkDialog } from "./scoring-link-dialog"
import { JudgeListDialog } from "./judge-list-dialog"
import {
Trophy, Plus, Edit, CheckCircle, AlertTriangle, ClipboardList, User, Users, Search, Loader2, BarChart3, ChevronLeft, ChevronRight, Link
} from "lucide-react"
interface ScoringRecord {
id: string
judgeId: string
judgeName: string
participantId: string
participantName: string
participantType: "individual" | "team"
scores: Record<string, number>
totalScore: number
comments: string
submittedAt: string
status: "completed" | "pending" | "draft"
}
const mockIndividualApps: any[] = []
const initialTeams: any[] = []
export function ScoringManagement() {
const { competitions, judges, judgeScores, submitJudgeScore } = useCompetition()
const [selectedCompetition, setSelectedCompetition] = useState<any>(null)
const [scoringRecords, setScoringRecords] = useState<ScoringRecord[]>([])
const [showManualScoring, setShowManualScoring] = useState(false)
const [showEditScoring, setShowEditScoring] = useState(false)
const [selectedRecord, setSelectedRecord] = useState<ScoringRecord | null>(null)
const [manualScoring, setManualScoring] = useState({
judgeId: "", participantId: "", scores: {} as Record<string, number>, comments: ""
})
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "pending">("all")
const [searchQuery, setSearchQuery] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState("")
const [error, setError] = useState("")
const [showScoringLink, setShowScoringLink] = useState(false)
const [showJudgeList, setShowJudgeList] = useState(false)
useEffect(() => {
if (selectedCompetition) {
loadScoringData()
}
}, [selectedCompetition])
const loadScoringData = () => {
if (!selectedCompetition) return
const participants = [
...(selectedCompetition.participatingApps || []).map((appId: string) => {
const app = mockIndividualApps.find(a => a.id === appId)
return { id: appId, name: app?.name || `應用 ${appId}`, type: "individual" as const }
}),
...(selectedCompetition.participatingTeams || []).map((teamId: string) => {
const team = initialTeams.find(t => t.id === teamId)
return { id: teamId, name: team?.name || `團隊 ${teamId}`, type: "team" as const }
})
]
const records: ScoringRecord[] = []
participants.forEach(participant => {
selectedCompetition.judges.forEach((judgeId: string) => {
const judge = judges.find(j => j.id === judgeId)
if (!judge) return
const existingScore = judgeScores.find(score =>
score.judgeId === judgeId && score.appId === participant.id
)
if (existingScore) {
records.push({
id: `${judgeId}-${participant.id}`,
judgeId, judgeName: judge.name,
participantId: participant.id, participantName: participant.name,
participantType: participant.type, scores: existingScore.scores,
totalScore: calculateTotalScore(existingScore.scores, selectedCompetition.rules || []),
comments: existingScore.comments,
submittedAt: existingScore.submittedAt || new Date().toISOString(),
status: "completed" as const,
})
} else {
// 初始化評分項目
const initialScores: Record<string, number> = {}
if (selectedCompetition.rules && selectedCompetition.rules.length > 0) {
selectedCompetition.rules.forEach((rule: any) => {
initialScores[rule.name] = 0
})
} else {
// 預設評分項目
initialScores.innovation = 0
initialScores.technical = 0
initialScores.usability = 0
initialScores.presentation = 0
initialScores.impact = 0
}
records.push({
id: `${judgeId}-${participant.id}`,
judgeId, judgeName: judge.name,
participantId: participant.id, participantName: participant.name,
participantType: participant.type, scores: initialScores,
totalScore: 0, comments: "", submittedAt: "",
status: "pending" as const,
})
}
})
})
setScoringRecords(records)
}
const calculateTotalScore = (scores: Record<string, number>, rules: any[]): number => {
if (rules.length === 0) {
const values = Object.values(scores)
return values.length > 0 ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0
}
let totalScore = 0
let totalWeight = 0
rules.forEach((rule: any) => {
const score = scores[rule.name] || 0
const weight = rule.weight || 1
totalScore += score * weight
totalWeight += weight
})
return totalWeight > 0 ? Math.round(totalScore / totalWeight) : 0
}
const getFilteredRecords = () => {
let filtered = [...scoringRecords]
if (statusFilter !== "all") {
filtered = filtered.filter(record => record.status === statusFilter)
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filtered = filtered.filter(record =>
record.judgeName.toLowerCase().includes(query) ||
record.participantName.toLowerCase().includes(query)
)
}
return filtered
}
const handleManualScoring = () => {
// 根據競賽規則初始化評分項目
const initialScores: Record<string, number> = {}
if (selectedCompetition?.rules && selectedCompetition.rules.length > 0) {
selectedCompetition.rules.forEach((rule: any) => {
initialScores[rule.name] = 0
})
} else {
// 預設評分項目
initialScores.innovation = 0
initialScores.technical = 0
initialScores.usability = 0
initialScores.presentation = 0
initialScores.impact = 0
}
setManualScoring({
judgeId: "",
participantId: "",
scores: initialScores,
comments: ""
})
setShowManualScoring(true)
}
const handleEditScoring = (record: ScoringRecord) => {
setSelectedRecord(record)
setManualScoring({
judgeId: record.judgeId,
participantId: record.participantId,
scores: { ...record.scores },
comments: record.comments,
})
setShowEditScoring(true)
}
const handleSubmitScore = async () => {
setError("")
if (!manualScoring.judgeId || !manualScoring.participantId) {
setError("請選擇評審和參賽項目")
return
}
// 檢查所有評分項目是否都已評分
const scoringRules = selectedCompetition?.rules || []
const defaultRules = [
{ name: "創新性" }, { name: "技術性" }, { name: "實用性" },
{ name: "展示效果" }, { name: "影響力" }
]
const rules = scoringRules.length > 0 ? scoringRules : defaultRules
const hasAllScores = rules.every((rule: any) =>
manualScoring.scores[rule.name] && manualScoring.scores[rule.name] > 0
)
if (!hasAllScores) {
setError("請為所有評分項目打分")
return
}
if (!manualScoring.comments.trim()) {
setError("請填寫評審意見")
return
}
setIsLoading(true)
try {
await submitJudgeScore({
judgeId: manualScoring.judgeId,
appId: manualScoring.participantId,
scores: manualScoring.scores,
comments: manualScoring.comments.trim(),
})
setSuccess(showEditScoring ? "評分更新成功!" : "評分提交成功!")
loadScoringData()
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
} catch (err) {
setError("評分提交失敗,請重試")
} finally {
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case "completed": return <Badge className="bg-green-100 text-green-800"></Badge>
case "pending": return <Badge className="bg-orange-100 text-orange-800"></Badge>
default: return <Badge variant="outline">{status}</Badge>
}
}
const getScoringProgress = () => {
const total = scoringRecords.length
const completed = scoringRecords.filter(r => r.status === "completed").length
const pending = scoringRecords.filter(r => r.status === "pending").length
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
return { total, completed, pending, percentage }
}
const progress = getScoringProgress()
return (
<div className="space-y-6">
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Trophy className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedCompetition?.id || ""} onValueChange={(value) => {
const competition = competitions.find(c => c.id === value)
setSelectedCompetition(competition)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder="選擇競賽" />
</SelectTrigger>
<SelectContent>
{competitions.map((competition) => (
<SelectItem key={competition.id} value={competition.id}>
<div className="flex flex-col">
<span className="font-medium">{competition.name}</span>
<span className="text-xs text-gray-500">
{competition.year}{competition.month} {competition.type === "individual" ? "個人賽" : competition.type === "team" ? "團體賽" : "混合賽"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedCompetition && (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="w-5 h-5" />
<span>{selectedCompetition.name} - </span>
<Badge variant="outline">
{selectedCompetition.type === "individual" ? "個人賽" : selectedCompetition.type === "team" ? "團體賽" : "混合賽"}
</Badge>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{progress.completed}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-orange-600">{progress.pending}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{progress.percentage}%</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">{progress.total}</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span>{progress.completed} / {progress.total}</span>
</div>
<Progress value={progress.percentage} className="h-2" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="flex items-center space-x-2">
<ClipboardList className="w-5 h-5" />
<span></span>
</CardTitle>
<div className="flex space-x-2">
<Button
onClick={() => setShowScoringLink(true)}
variant="outline"
className="border-blue-200 text-blue-600 hover:bg-blue-50"
>
<Link className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => setShowJudgeList(true)}
variant="outline"
>
<Users className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleManualScoring} variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4 mb-6">
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium"></span>
<Select value={statusFilter} onValueChange={(value: any) => setStatusFilter(value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋評審或參賽者..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{(() => {
// 按評審分組
const groupedByJudge = getFilteredRecords().reduce((groups, record) => {
const judgeName = record.judgeName
if (!groups[judgeName]) {
groups[judgeName] = []
}
groups[judgeName].push(record)
return groups
}, {} as Record<string, ScoringRecord[]>)
return Object.entries(groupedByJudge).map(([judgeName, records]) => {
const completedCount = records.filter(r => r.status === "completed").length
const totalCount = records.length
const progressPercentage = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
return (
<Card key={judgeName} className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="text-sm font-semibold">
{judgeName.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-lg font-semibold">{judgeName}</h3>
<p className="text-sm text-gray-600">
{completedCount} / {totalCount}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">{progressPercentage}%</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="w-16 h-16 relative">
<svg className="w-16 h-16 transform -rotate-90" viewBox="0 0 36 36">
<path
className="text-gray-200"
stroke="currentColor"
strokeWidth="2"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className="text-blue-600"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeDasharray={`${progressPercentage}, 100`}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-semibold">{progressPercentage}%</span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative px-6">
{/* 左滑動箭頭 */}
{records.length > 4 && (
<button
onClick={() => {
const container = document.getElementById(`scroll-${judgeName}`)
if (container) {
container.scrollLeft -= 280 // 滑動一個卡片的寬度
}
}}
className="absolute -left-6 top-1/2 transform -translate-y-1/2 z-10 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 hover:bg-gray-50"
>
<ChevronLeft className="w-4 h-4 text-gray-600" />
</button>
)}
{/* 右滑動箭頭 */}
{records.length > 4 && (
<button
onClick={() => {
const container = document.getElementById(`scroll-${judgeName}`)
if (container) {
container.scrollLeft += 280 // 滑動一個卡片的寬度
}
}}
className="absolute -right-6 top-1/2 transform -translate-y-1/2 z-10 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 hover:bg-gray-50"
>
<ChevronRight className="w-4 h-4 text-gray-600" />
</button>
)}
<div
id={`scroll-${judgeName}`}
className="flex space-x-4 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
maxWidth: 'calc(4 * 256px + 3 * 16px)' // 4個卡片 + 3個間距
}}
>
{records.map((record) => (
<div
key={record.id}
className="flex-shrink-0 w-64 bg-white border rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-200"
>
<div className="space-y-3">
{/* 項目標題和類型 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{record.participantType === "individual" ? (
<User className="w-4 h-4 text-blue-600" />
) : (
<Users className="w-4 h-4 text-green-600" />
)}
<span className="font-medium text-sm truncate">{record.participantName}</span>
</div>
<Badge variant="outline" className="text-xs">
{record.participantType === "individual" ? "個人" : "團隊"}
</Badge>
</div>
{/* 評分狀態 */}
<div className="flex items-center justify-between">
<div className="text-center">
<div className="flex items-center space-x-1">
<span className="font-bold text-lg">{record.totalScore}</span>
<span className="text-gray-500 text-sm">/ 10</span>
</div>
</div>
<div className="flex flex-col items-end space-y-1">
{getStatusBadge(record.status)}
{record.submittedAt && (
<span className="text-xs text-gray-500">
{new Date(record.submittedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{/* 操作按鈕 */}
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditScoring(record)}
className="w-full"
>
{record.status === "completed" ? (
<>
<Edit className="w-4 h-4 mr-2" />
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
})
})()}
</div>
</CardContent>
</Card>
</>
)}
<Dialog open={showManualScoring || showEditScoring} onOpenChange={(open) => {
if (!open) {
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
setManualScoring({
judgeId: "",
participantId: "",
scores: {} as Record<string, number>,
comments: ""
})
}
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Edit className="w-5 h-5" />
<span>{showEditScoring ? "編輯評分" : "手動輸入評分"}</span>
</DialogTitle>
<DialogDescription>
{showEditScoring ? "修改現有評分記錄" : "為參賽者手動輸入評分"}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Select
value={manualScoring.judgeId}
onValueChange={(value) => setManualScoring({ ...manualScoring, judgeId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇評審" />
</SelectTrigger>
<SelectContent>
{judges.map((judge) => (
<SelectItem key={judge.id} value={judge.id}>
{judge.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={manualScoring.participantId}
onValueChange={(value) => setManualScoring({ ...manualScoring, participantId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇參賽者" />
</SelectTrigger>
<SelectContent>
{[
...(selectedCompetition?.participatingApps || []).map((appId: string) => {
const app = mockIndividualApps.find(a => a.id === appId)
return { id: appId, name: app?.name || `應用 ${appId}`, type: "individual" }
}),
...(selectedCompetition?.participatingTeams || []).map((teamId: string) => {
const team = initialTeams.find(t => t.id === teamId)
return { id: teamId, name: team?.name || `團隊 ${teamId}`, type: "team" }
})
].map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center space-x-2">
{participant.type === "individual" ? (
<User className="w-4 h-4 text-blue-600" />
) : (
<Users className="w-4 h-4 text-green-600" />
)}
<span>{participant.name}</span>
<Badge variant="outline" className="text-xs">
{participant.type === "individual" ? "個人" : "團隊"}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 動態評分項目 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
{(() => {
const scoringRules = selectedCompetition?.rules || []
const defaultRules = [
{ name: "創新性", description: "創新程度和獨特性", weight: 25 },
{ name: "技術性", description: "技術實現的複雜度和品質", weight: 30 },
{ name: "實用性", description: "實際應用價值和用戶體驗", weight: 20 },
{ name: "展示效果", description: "展示的清晰度和吸引力", weight: 15 },
{ name: "影響力", description: "對行業或社會的潛在影響", weight: 10 }
]
const rules = scoringRules.length > 0 ? scoringRules : defaultRules
return rules.map((rule: any, index: number) => (
<div key={index} className="space-y-4 p-6 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1">
<Label className="text-lg font-semibold text-gray-900">{rule.name}</Label>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{rule.description}</p>
{rule.weight && (
<p className="text-xs text-purple-600 mt-2 font-medium">{rule.weight}%</p>
)}
</div>
<div className="text-right ml-4">
<span className="text-2xl font-bold text-blue-600">
{manualScoring.scores[rule.name] || 0} / 10
</span>
</div>
</div>
{/* 評分按鈕 */}
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<button
key={score}
type="button"
onClick={() => setManualScoring({
...manualScoring,
scores: { ...manualScoring.scores, [rule.name]: score }
})}
className={`w-12 h-12 rounded-lg border-2 font-semibold text-lg transition-all duration-200 ${
(manualScoring.scores[rule.name] || 0) === score
? 'bg-blue-600 text-white border-blue-600 shadow-lg scale-105'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50 hover:scale-105'
}`}
>
{score}
</button>
))}
</div>
</div>
))
})()}
</div>
{/* 總分顯示 */}
<div className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200">
<div className="flex justify-between items-center">
<div>
<span className="text-xl font-bold text-gray-900"></span>
<p className="text-sm text-gray-600 mt-1"></p>
</div>
<div className="flex items-center space-x-3">
<span className="text-4xl font-bold text-blue-600">
{calculateTotalScore(manualScoring.scores, selectedCompetition?.rules || [])}
</span>
<span className="text-xl text-gray-500 font-medium">/ 10</span>
</div>
</div>
</div>
<div className="space-y-3">
<Label className="text-lg font-semibold"> *</Label>
<Textarea
placeholder="請詳細填寫評審意見、優點分析、改進建議等..."
value={manualScoring.comments}
onChange={(e) => setManualScoring({ ...manualScoring, comments: e.target.value })}
rows={6}
className="min-h-[120px] resize-none"
/>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6 border-t border-gray-200">
<Button
variant="outline"
size="lg"
onClick={() => {
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
}}
className="px-8"
>
</Button>
<Button
onClick={handleSubmitScore}
disabled={isLoading}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
<>
<CheckCircle className="w-5 h-5 mr-2" />
{showEditScoring ? "更新評分" : "提交評分"}
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 評分連結對話框 */}
<ScoringLinkDialog
open={showScoringLink}
onOpenChange={setShowScoringLink}
currentCompetition={selectedCompetition}
/>
{/* 評審清單對話框 */}
<JudgeListDialog
open={showJudgeList}
onOpenChange={setShowJudgeList}
judges={selectedCompetition ?
judges
.filter(judge => selectedCompetition.judges.includes(judge.id))
.map(judge => ({
id: judge.id,
name: judge.name,
specialty: "評審專家"
})) : []
}
/>
</div>
)
}

View File

@@ -0,0 +1,520 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Settings,
Shield,
Mail,
Server,
Users,
Bell,
Save,
TestTube,
CheckCircle,
HardDrive,
Clock,
Globe,
} from "lucide-react"
export function SystemSettings() {
const [settings, setSettings] = useState({
// 一般設定
siteName: "AI應用展示平台",
siteDescription: "展示和分享AI應用的專業平台",
timezone: "Asia/Taipei",
language: "zh-TW",
maintenanceMode: false,
// 安全設定
twoFactorAuth: true,
sessionTimeout: 30,
maxLoginAttempts: 5,
passwordMinLength: 8,
// 郵件設定
smtpHost: "smtp.gmail.com",
smtpPort: "587",
smtpUser: "",
smtpPassword: "",
smtpEncryption: "tls",
// 系統性能
cacheEnabled: true,
cacheTimeout: 3600,
maxFileSize: 10,
maxUploadSize: 50,
// 用戶管理
allowRegistration: true,
emailVerification: true,
defaultUserRole: "user",
// 通知設定
systemNotifications: true,
emailNotifications: true,
slackWebhook: "",
notificationFrequency: "immediate",
})
const [activeTab, setActiveTab] = useState("general")
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
const handleSave = async () => {
setSaveStatus("saving")
// 模擬保存過程
setTimeout(() => {
setSaveStatus("saved")
setTimeout(() => setSaveStatus("idle"), 2000)
}, 1000)
}
const handleTestEmail = () => {
// 測試郵件功能
alert("測試郵件已發送!")
}
const updateSetting = (key: string, value: any) => {
setSettings((prev) => ({ ...prev, [key]: value }))
}
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Settings className="w-8 h-8 text-blue-600" />
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<Button onClick={handleSave} disabled={saveStatus === "saving"}>
<Save className="w-4 h-4 mr-2" />
{saveStatus === "saving" ? "保存中..." : saveStatus === "saved" ? "已保存" : "保存設定"}
</Button>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="general" className="flex items-center gap-2">
<Globe className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="email" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="performance" className="flex items-center gap-2">
<Server className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="notifications" className="flex items-center gap-2">
<Bell className="w-4 h-4" />
</TabsTrigger>
</TabsList>
{/* 一般設定 */}
<TabsContent value="general" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Input
id="siteName"
value={settings.siteName}
onChange={(e) => updateSetting("siteName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone"></Label>
<Select value={settings.timezone} onValueChange={(value) => updateSetting("timezone", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Asia/Taipei"> (UTC+8)</SelectItem>
<SelectItem value="Asia/Tokyo"> (UTC+9)</SelectItem>
<SelectItem value="UTC">UTC (UTC+0)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="siteDescription"></Label>
<Textarea
id="siteDescription"
value={settings.siteDescription}
onChange={(e) => updateSetting("siteDescription", e.target.value)}
rows={3}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.maintenanceMode}
onCheckedChange={(checked) => updateSetting("maintenanceMode", checked)}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 安全設定 */}
<TabsContent value="security" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="flex items-center gap-2">
<Badge variant={settings.twoFactorAuth ? "default" : "secondary"}>
{settings.twoFactorAuth ? "已啟用" : "已停用"}
</Badge>
<Switch
checked={settings.twoFactorAuth}
onCheckedChange={(checked) => updateSetting("twoFactorAuth", checked)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sessionTimeout"> ()</Label>
<Input
id="sessionTimeout"
type="number"
value={settings.sessionTimeout}
onChange={(e) => updateSetting("sessionTimeout", Number.parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxLoginAttempts"></Label>
<Input
id="maxLoginAttempts"
type="number"
value={settings.maxLoginAttempts}
onChange={(e) => updateSetting("maxLoginAttempts", Number.parseInt(e.target.value))}
/>
</div>
</div>
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800">
<CheckCircle className="w-5 h-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-green-700 mt-1"></p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 郵件設定 */}
<TabsContent value="email" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
SMTP
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="smtpHost">SMTP </Label>
<Input
id="smtpHost"
value={settings.smtpHost}
onChange={(e) => updateSetting("smtpHost", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtpPort">SMTP </Label>
<Input
id="smtpPort"
value={settings.smtpPort}
onChange={(e) => updateSetting("smtpPort", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="smtpUser">SMTP </Label>
<Input
id="smtpUser"
value={settings.smtpUser}
onChange={(e) => updateSetting("smtpUser", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label>
<Input
id="smtpPassword"
type="password"
value={settings.smtpPassword}
onChange={(e) => updateSetting("smtpPassword", e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-4">
<Button onClick={handleTestEmail} variant="outline">
<TestTube className="w-4 h-4 mr-2" />
</Button>
<Badge variant="outline" className="text-green-600 border-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
</Badge>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 系統性能 */}
<TabsContent value="performance" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.cacheEnabled}
onCheckedChange={(checked) => updateSetting("cacheEnabled", checked)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxFileSize"> (MB)</Label>
<Input
id="maxFileSize"
type="number"
value={settings.maxFileSize}
onChange={(e) => updateSetting("maxFileSize", Number.parseInt(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxUploadSize"> (MB)</Label>
<Input
id="maxUploadSize"
type="number"
value={settings.maxUploadSize}
onChange={(e) => updateSetting("maxUploadSize", Number.parseInt(e.target.value))}
/>
</div>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2 text-blue-800 mb-2">
<HardDrive className="w-5 h-5" />
<span className="font-medium">使</span>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>使</span>
<span>2.3 GB / 10 GB</span>
</div>
<div className="w-full bg-blue-200 rounded-full h-2">
<div className="bg-blue-600 h-2 rounded-full" style={{ width: "23%" }}></div>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 用戶管理 */}
<TabsContent value="users" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.allowRegistration}
onCheckedChange={(checked) => updateSetting("allowRegistration", checked)}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground">使</p>
</div>
<Switch
checked={settings.emailVerification}
onCheckedChange={(checked) => updateSetting("emailVerification", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultUserRole"></Label>
<Select
value={settings.defaultUserRole}
onValueChange={(value) => updateSetting("defaultUserRole", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="contributor"></SelectItem>
<SelectItem value="moderator"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">2,847</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">356</div>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">23</div>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 通知設定 */}
<TabsContent value="notifications" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.systemNotifications}
onCheckedChange={(checked) => updateSetting("systemNotifications", checked)}
/>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="space-y-1">
<Label></Label>
<p className="text-sm text-muted-foreground"></p>
</div>
<Switch
checked={settings.emailNotifications}
onCheckedChange={(checked) => updateSetting("emailNotifications", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="slackWebhook">Slack Webhook URL</Label>
<Input
id="slackWebhook"
placeholder="https://hooks.slack.com/services/..."
value={settings.slackWebhook}
onChange={(e) => updateSetting("slackWebhook", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="notificationFrequency"></Label>
<Select
value={settings.notificationFrequency}
onValueChange={(value) => updateSetting("notificationFrequency", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate"></SelectItem>
<SelectItem value="hourly"></SelectItem>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 text-yellow-800 mb-2">
<Clock className="w-5 h-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-yellow-700">24 12 </p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{saveStatus === "saved" && (
<div className="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2">
<CheckCircle className="w-4 h-4" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,855 @@
"use client"
import { useState } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Alert, AlertDescription } from "@/components/ui/alert"
import {
Users,
Plus,
MoreHorizontal,
Edit,
Trash2,
Eye,
UserPlus,
UserMinus,
Crown,
CheckCircle,
AlertTriangle,
Loader2,
} from "lucide-react"
import type { Team, TeamMember } from "@/types/competition"
export function TeamManagement() {
const { teams, addTeam, updateTeam, getTeamById } = useCompetition()
const [searchTerm, setSearchTerm] = useState("")
const [selectedDepartment, setSelectedDepartment] = useState("all")
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null)
const [showTeamDetail, setShowTeamDetail] = useState(false)
const [showAddTeam, setShowAddTeam] = useState(false)
const [showEditTeam, setShowEditTeam] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState("")
const [error, setError] = useState("")
const [newTeam, setNewTeam] = useState({
name: "",
department: "HQBU",
contactEmail: "",
members: [] as TeamMember[],
leader: "",
apps: [] as string[],
totalLikes: 0,
})
const [newMember, setNewMember] = useState({
name: "",
department: "HQBU",
role: "成員",
})
const filteredTeams = teams.filter((team) => {
const matchesSearch =
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
team.members.some((member) => member.name.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesDepartment = selectedDepartment === "all" || team.department === selectedDepartment
return matchesSearch && matchesDepartment
})
const handleViewTeam = (team: Team) => {
setSelectedTeam(team)
setShowTeamDetail(true)
}
const handleEditTeam = (team: Team) => {
setSelectedTeam(team)
setNewTeam({
name: team.name,
department: team.department,
contactEmail: team.contactEmail,
members: [...team.members],
leader: team.leader,
apps: [...team.apps],
totalLikes: team.totalLikes,
})
setShowEditTeam(true)
}
const handleDeleteTeam = (team: Team) => {
setSelectedTeam(team)
setShowDeleteConfirm(true)
}
const confirmDeleteTeam = () => {
if (selectedTeam) {
// In a real app, you would call a delete function here
setShowDeleteConfirm(false)
setSelectedTeam(null)
setSuccess("團隊刪除成功!")
setTimeout(() => setSuccess(""), 3000)
}
}
const handleAddMember = () => {
if (!newMember.name.trim()) {
setError("請輸入成員姓名")
return
}
const member: TeamMember = {
id: `m${Date.now()}`,
name: newMember.name,
department: newMember.department,
role: newMember.role,
}
setNewTeam({
...newTeam,
members: [...newTeam.members, member],
})
// Set as leader if it's the first member
if (newTeam.members.length === 0) {
setNewTeam((prev) => ({
...prev,
leader: member.id,
members: [...prev.members, { ...member, role: "隊長" }],
}))
}
setNewMember({
name: "",
department: "HQBU",
role: "成員",
})
setError("")
}
const handleRemoveMember = (memberId: string) => {
const updatedMembers = newTeam.members.filter((m) => m.id !== memberId)
let newLeader = newTeam.leader
// If removing the leader, assign leadership to the first remaining member
if (memberId === newTeam.leader && updatedMembers.length > 0) {
newLeader = updatedMembers[0].id
updatedMembers[0].role = "隊長"
}
setNewTeam({
...newTeam,
members: updatedMembers,
leader: newLeader,
})
}
const handleSetLeader = (memberId: string) => {
const updatedMembers = newTeam.members.map((member) => ({
...member,
role: member.id === memberId ? "隊長" : "成員",
}))
setNewTeam({
...newTeam,
members: updatedMembers,
leader: memberId,
})
}
const handleAddTeam = async () => {
setError("")
if (!newTeam.name || !newTeam.contactEmail || newTeam.members.length === 0) {
setError("請填寫所有必填欄位並至少添加一名成員")
return
}
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
addTeam(newTeam)
setShowAddTeam(false)
setNewTeam({
name: "",
department: "HQBU",
contactEmail: "",
members: [],
leader: "",
apps: [],
totalLikes: 0,
})
setSuccess("團隊創建成功!")
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
const handleUpdateTeam = async () => {
if (!selectedTeam) return
setError("")
if (!newTeam.name || !newTeam.contactEmail || newTeam.members.length === 0) {
setError("請填寫所有必填欄位並至少添加一名成員")
return
}
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
updateTeam(selectedTeam.id, newTeam)
setShowEditTeam(false)
setSelectedTeam(null)
setSuccess("團隊更新成功!")
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
return (
<div className="space-y-6">
{/* Success/Error Messages */}
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600"></p>
</div>
<Button
onClick={() => setShowAddTeam(true)}
className="bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{teams.length}</p>
</div>
<Users className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">{teams.reduce((sum, team) => sum + team.members.length, 0)}</p>
</div>
<UserPlus className="w-8 h-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600"></p>
<p className="text-2xl font-bold">
{teams.length > 0
? Math.round((teams.reduce((sum, team) => sum + team.members.length, 0) / teams.length) * 10) / 10
: 0}
</p>
</div>
<Crown className="w-8 h-8 text-yellow-600" />
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col lg:flex-row gap-4 items-center">
<div className="flex-1 relative">
<Input
placeholder="搜尋團隊名稱或成員姓名..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-3">
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
<SelectTrigger className="w-32">
<SelectValue placeholder="部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Teams Table */}
<Card>
<CardHeader>
<CardTitle> ({filteredTeams.length})</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTeams.map((team) => {
const leader = team.members.find((m) => m.id === team.leader)
return (
<TableRow key={team.id}>
<TableCell>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-green-500 to-blue-500 rounded-lg flex items-center justify-center">
<Users className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-medium">{team.name}</p>
<p className="text-sm text-gray-500">{team.contactEmail}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Avatar className="w-6 h-6">
<AvatarFallback className="bg-green-100 text-green-700 text-xs">
{leader?.name[0] || "?"}
</AvatarFallback>
</Avatar>
<span className="text-sm">{leader?.name || "未設定"}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-gray-100 text-gray-700">
{team.department}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center space-x-1">
<UserPlus className="w-4 h-4 text-blue-500" />
<span>{team.members.length}</span>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{team.apps.length}</span>
</TableCell>
<TableCell>
<span className="font-medium text-red-600">{team.totalLikes}</span>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewTeam(team)}>
<Eye className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEditTeam(team)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600" onClick={() => handleDeleteTeam(team)}>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Add Team Dialog */}
<Dialog open={showAddTeam} onOpenChange={setShowAddTeam}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="members"></TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="teamName"> *</Label>
<Input
id="teamName"
value={newTeam.name}
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
placeholder="輸入團隊名稱"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamDepartment"></Label>
<Select
value={newTeam.department}
onValueChange={(value) => setNewTeam({ ...newTeam, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="contactEmail"> *</Label>
<Input
id="contactEmail"
type="email"
value={newTeam.contactEmail}
onChange={(e) => setNewTeam({ ...newTeam, contactEmail: e.target.value })}
placeholder="team@company.com"
/>
</div>
</TabsContent>
<TabsContent value="members" className="space-y-4">
<div className="space-y-4">
<h4 className="font-semibold"></h4>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="memberName"></Label>
<Input
id="memberName"
value={newMember.name}
onChange={(e) => setNewMember({ ...newMember, name: e.target.value })}
placeholder="輸入成員姓名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="memberDepartment"></Label>
<Select
value={newMember.department}
onValueChange={(value) => setNewMember({ ...newMember, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="memberRole"></Label>
<Input
id="memberRole"
value={newMember.role}
onChange={(e) => setNewMember({ ...newMember, role: e.target.value })}
placeholder="例如:開發工程師"
/>
</div>
</div>
<Button onClick={handleAddMember} variant="outline" className="w-full bg-transparent">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
{newTeam.members.length > 0 && (
<div className="space-y-4">
<h4 className="font-semibold"> ({newTeam.members.length})</h4>
<div className="space-y-2">
{newTeam.members.map((member) => (
<div key={member.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center space-x-3">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-green-100 text-green-700 text-sm">
{member.name[0]}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center space-x-2">
<span className="font-medium">{member.name}</span>
{member.id === newTeam.leader && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
</Badge>
)}
</div>
<div className="text-sm text-gray-600">
{member.department} {member.role}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{member.id !== newTeam.leader && (
<Button variant="outline" size="sm" onClick={() => handleSetLeader(member.id)}>
<Crown className="w-4 h-4 mr-1" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveMember(member.id)}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<UserMinus className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowAddTeam(false)}>
</Button>
<Button onClick={handleAddTeam} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"創建團隊"
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit Team Dialog */}
<Dialog open={showEditTeam} onOpenChange={setShowEditTeam}>
<DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="members"></TabsTrigger>
</TabsList>
<TabsContent value="basic" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="editTeamName"> *</Label>
<Input
id="editTeamName"
value={newTeam.name}
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
placeholder="輸入團隊名稱"
/>
</div>
<div className="space-y-2">
<Label htmlFor="editTeamDepartment"></Label>
<Select
value={newTeam.department}
onValueChange={(value) => setNewTeam({ ...newTeam, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="editContactEmail"> *</Label>
<Input
id="editContactEmail"
type="email"
value={newTeam.contactEmail}
onChange={(e) => setNewTeam({ ...newTeam, contactEmail: e.target.value })}
placeholder="team@company.com"
/>
</div>
</TabsContent>
<TabsContent value="members" className="space-y-4">
<div className="space-y-4">
<h4 className="font-semibold"></h4>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="editMemberName"></Label>
<Input
id="editMemberName"
value={newMember.name}
onChange={(e) => setNewMember({ ...newMember, name: e.target.value })}
placeholder="輸入成員姓名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="editMemberDepartment"></Label>
<Select
value={newMember.department}
onValueChange={(value) => setNewMember({ ...newMember, department: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="editMemberRole"></Label>
<Input
id="editMemberRole"
value={newMember.role}
onChange={(e) => setNewMember({ ...newMember, role: e.target.value })}
placeholder="例如:開發工程師"
/>
</div>
</div>
<Button onClick={handleAddMember} variant="outline" className="w-full bg-transparent">
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
{newTeam.members.length > 0 && (
<div className="space-y-4">
<h4 className="font-semibold"> ({newTeam.members.length})</h4>
<div className="space-y-2">
{newTeam.members.map((member) => (
<div key={member.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center space-x-3">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-green-100 text-green-700 text-sm">
{member.name[0]}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center space-x-2">
<span className="font-medium">{member.name}</span>
{member.id === newTeam.leader && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
</Badge>
)}
</div>
<div className="text-sm text-gray-600">
{member.department} {member.role}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{member.id !== newTeam.leader && (
<Button variant="outline" size="sm" onClick={() => handleSetLeader(member.id)}>
<Crown className="w-4 h-4 mr-1" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveMember(member.id)}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<UserMinus className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => setShowEditTeam(false)}>
</Button>
<Button onClick={handleUpdateTeam} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"更新團隊"
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
<span></span>
</DialogTitle>
<DialogDescription>
{selectedTeam?.name}
<br />
<span className="text-red-600 font-medium"></span>
</DialogDescription>
</DialogHeader>
<div className="flex justify-end space-x-3 mt-6">
<Button variant="outline" onClick={() => setShowDeleteConfirm(false)}>
</Button>
<Button variant="destructive" onClick={confirmDeleteTeam}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</DialogContent>
</Dialog>
{/* Team Detail Dialog */}
<Dialog open={showTeamDetail} onOpenChange={setShowTeamDetail}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{selectedTeam && (
<div className="space-y-6">
<div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-green-500 to-blue-500 rounded-xl flex items-center justify-center">
<Users className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold">{selectedTeam.name}</h3>
<p className="text-gray-600 mb-2">{selectedTeam.contactEmail}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-gray-100 text-gray-700">
{selectedTeam.department}
</Badge>
<Badge variant="outline" className="bg-blue-100 text-blue-700">
{selectedTeam.members.length}
</Badge>
<Badge variant="outline" className="bg-green-100 text-green-700">
{selectedTeam.apps.length}
</Badge>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">ID</p>
<p className="font-medium">{selectedTeam.id}</p>
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium text-red-600">{selectedTeam.totalLikes}</p>
</div>
</div>
<div>
<h4 className="font-semibold mb-3"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{selectedTeam.members.map((member) => (
<div key={member.id} className="flex items-center space-x-3 p-3 border rounded-lg">
<Avatar className="w-10 h-10">
<AvatarFallback className="bg-green-100 text-green-700">{member.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2">
<span className="font-medium">{member.name}</span>
{member.id === selectedTeam.leader && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
</Badge>
)}
</div>
<div className="text-sm text-gray-600">
{member.department} {member.role}
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,466 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Star,
Eye,
Heart,
Info,
MessageSquare,
User,
Calendar,
Building,
TrendingUp,
Users,
BarChart3,
} from "lucide-react"
import { FavoriteButton } from "./favorite-button"
import { ReviewSystem } from "./reviews/review-system"
interface AppDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
app: {
id: number
name: string
type: string
department: string
description: string
icon: any
creator: string
featured: boolean
judgeScore: number
}
}
// Usage statistics data - empty for production
const getAppUsageStats = (appId: string, startDate: string, endDate: string) => {
return {
dailyUsers: 0,
weeklyUsers: 0,
monthlyUsers: 0,
totalSessions: 0,
topDepartments: [],
trendData: [],
}
}
export function AppDetailDialog({ open, onOpenChange, app }: AppDetailDialogProps) {
const { user, addToRecentApps, getAppLikes, incrementViewCount, getViewCount, getAppRating } = useAuth()
const [currentRating, setCurrentRating] = useState(getAppRating(app.id.toString()))
const [reviewCount, setReviewCount] = useState(0)
// Date range for usage trends
const [startDate, setStartDate] = useState(() => {
const date = new Date()
date.setDate(date.getDate() - 6) // Default to last 7 days
return date.toISOString().split("T")[0]
})
const [endDate, setEndDate] = useState(() => {
return new Date().toISOString().split("T")[0]
})
const IconComponent = app.icon
const likes = getAppLikes(app.id.toString())
const views = getViewCount(app.id.toString())
const usageStats = getAppUsageStats(app.id.toString(), startDate, endDate)
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const handleRatingUpdate = (newRating: number, newReviewCount: number) => {
setCurrentRating(newRating)
setReviewCount(newReviewCount)
}
const handleTryApp = () => {
if (user) {
addToRecentApps(app.id.toString())
}
// Increment view count when trying the app
incrementViewCount(app.id.toString())
// Open external app URL in new tab
const appUrls: Record<string, string> = {
"1": "https://dify.example.com/chat-assistant",
"2": "https://image-gen.example.com",
"3": "https://speech.example.com",
"4": "https://recommend.example.com",
"5": "https://text-analysis.example.com",
"6": "https://ai-writing.example.com",
}
const appUrl = appUrls[app.id.toString()]
if (appUrl) {
window.open(appUrl, "_blank", "noopener,noreferrer")
}
}
// Helper function to group data by month/year for section headers
const getDateSections = (trendData: any[]) => {
const sections: { [key: string]: { startIndex: number; endIndex: number; label: string } } = {}
trendData.forEach((day, index) => {
const date = new Date(day.date)
const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
const label = date.toLocaleDateString("zh-TW", { year: "numeric", month: "long" })
if (!sections[yearMonth]) {
sections[yearMonth] = { startIndex: index, endIndex: index, label }
} else {
sections[yearMonth].endIndex = index
}
})
return sections
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center">
<IconComponent className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<DialogTitle className="text-2xl font-bold mb-2">{app.name}</DialogTitle>
<DialogDescription className="text-base mb-3">{app.description}</DialogDescription>
<div className="flex flex-wrap gap-2 mb-4">
<Badge variant="outline" className={getTypeColor(app.type)}>
{app.type}
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge>
{app.featured && (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 border-yellow-200">
<Star className="w-3 h-3 mr-1" />
</Badge>
)}
</div>
<div className="flex items-center space-x-6 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<Heart className="w-4 h-4" />
<span>{likes} </span>
</div>
<div className="flex items-center space-x-1">
<Eye className="w-4 h-4" />
<span>{views} </span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-4 h-4 text-yellow-500" />
<span>{currentRating.toFixed(1)}</span>
{reviewCount > 0 && <span className="text-gray-500">({reviewCount} )</span>}
</div>
</div>
</div>
</div>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview" className="flex items-center space-x-2">
<Info className="w-4 h-4" />
<span></span>
</TabsTrigger>
<TabsTrigger value="statistics" className="flex items-center space-x-2">
<BarChart3 className="w-4 h-4" />
<span>使</span>
</TabsTrigger>
<TabsTrigger value="reviews" className="flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span></span>
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center space-x-3">
<User className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{app.creator}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Building className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">{app.department}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Calendar className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm text-gray-500"></p>
<p className="font-medium">2024115</p>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-500 mb-2"></p>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
<div className="pt-4 border-t">
<p className="text-sm text-gray-500 mb-2"></p>
<p className="text-gray-700 leading-relaxed">
{app.description}
使
</p>
</div>
</CardContent>
</Card>
<div className="flex items-center space-x-4">
<Button
onClick={handleTryApp}
className="flex-1 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
</Button>
<FavoriteButton appId={app.id.toString()} size="default" showText={true} className="px-6" />
</div>
</TabsContent>
<TabsContent value="statistics" className="space-y-6">
{/* Usage Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<Users className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.dailyUsers}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<TrendingUp className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.weeklyUsers}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<BarChart3 className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{usageStats.totalSessions.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
</div>
{/* Usage Trends with Date Range */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2">
<Label htmlFor="start-date" className="text-sm">
</Label>
<Input
id="start-date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36"
/>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="end-date" className="text-sm">
</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36"
/>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Chart Container with Horizontal Scroll */}
<div className="w-full overflow-x-auto">
<div
className="h-80 relative bg-gray-50 rounded-lg p-4"
style={{
minWidth: `${Math.max(800, usageStats.trendData.length * 40)}px`, // Dynamic width based on data points
}}
>
{/* Month/Year Section Headers */}
<div className="absolute top-2 left-4 right-4 flex">
{(() => {
const sections = getDateSections(usageStats.trendData)
const totalBars = usageStats.trendData.length
return Object.entries(sections).map(([key, section]) => {
const width = ((section.endIndex - section.startIndex + 1) / totalBars) * 100
const left = (section.startIndex / totalBars) * 100
return (
<div
key={key}
className="absolute text-xs font-medium text-gray-600 bg-white/90 px-2 py-1 rounded shadow-sm border"
style={{
left: `${left}%`,
width: `${width}%`,
textAlign: "center",
}}
>
{section.label}
</div>
)
})
})()}
</div>
{/* Chart Bars */}
<div className="h-full flex items-end justify-between space-x-2" style={{ paddingTop: "40px" }}>
{usageStats.trendData.map((day, index) => {
const maxUsers = Math.max(...usageStats.trendData.map((d) => d.users))
const minUsers = Math.min(...usageStats.trendData.map((d) => d.users))
const range = maxUsers - minUsers
const normalizedHeight = range > 0 ? ((day.users - minUsers) / range) * 70 + 15 : 40
const currentDate = new Date(day.date)
const prevDate = index > 0 ? new Date(usageStats.trendData[index - 1].date) : null
// Check if this is the start of a new month/year for divider
const isNewMonth =
!prevDate ||
currentDate.getMonth() !== prevDate.getMonth() ||
currentDate.getFullYear() !== prevDate.getFullYear()
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center group relative"
style={{ minWidth: "32px" }}
>
{/* Month divider line */}
{isNewMonth && index > 0 && (
<div className="absolute left-0 top-0 bottom-8 w-px bg-gray-300 opacity-50" />
)}
<div
className="w-full flex flex-col items-center justify-end"
style={{ height: "200px" }}
>
<div
className="w-full bg-gradient-to-t from-blue-500 to-purple-500 rounded-t-md transition-all duration-300 hover:from-blue-600 hover:to-purple-600 cursor-pointer relative"
style={{ height: `${normalizedHeight}%` }}
>
{/* Value label */}
<div className="absolute -top-5 left-1/2 transform -translate-x-1/2 text-xs font-medium text-gray-600 bg-white/80 px-1 rounded">
{day.users}
</div>
</div>
</div>
{/* Consistent day-only labels */}
<div className="text-xs text-gray-500 mt-2 text-center">{currentDate.getDate()}</div>
</div>
)
})}
</div>
</div>
</div>
{/* Scroll Hint */}
{usageStats.trendData.length > 20 && (
<div className="text-xs text-gray-500 text-center">💡 </div>
)}
</div>
</CardContent>
</Card>
{/* Department Usage */}
<Card>
<CardHeader>
<CardTitle>使</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{usageStats.topDepartments.map((dept) => (
<div key={dept.name} className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full" />
<span className="font-medium">{dept.name}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">{dept.users} </span>
<span className="text-sm font-medium">{dept.percentage}%</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reviews" className="space-y-6">
<ReviewSystem
appId={app.id.toString()}
appName={app.name}
currentRating={currentRating}
onRatingUpdate={handleRatingUpdate}
/>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,668 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import {
Upload,
FileText,
Link,
CheckCircle,
Clock,
Info,
Lightbulb,
Target,
Zap,
AlertTriangle,
FileVideo,
X,
} from "lucide-react"
interface AppSubmissionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AppSubmissionDialog({ open, onOpenChange }: AppSubmissionDialogProps) {
const { user, canSubmitApp } = useAuth()
const [step, setStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [formData, setFormData] = useState({
name: "",
type: "文字處理",
description: "",
appUrl: "",
demoFile: null as File | null,
sourceCodeUrl: "",
documentation: "",
features: "",
technicalDetails: "",
requestFeatured: false,
agreeTerms: false,
})
// 檢查用戶權限
if (!user || !canSubmitApp()) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="w-5 h-5 text-orange-600" />
<span></span>
</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="text-center py-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-orange-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4">
AI {user?.role === "admin" ? "管理員" : "一般用戶"}
</p>
<div className="bg-blue-50 rounded-lg p-4 mb-4">
<div className="flex items-start space-x-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-left">
<p className="text-sm font-medium text-blue-900"></p>
<ul className="text-sm text-blue-700 mt-1 space-y-1">
<li> </li>
<li> </li>
<li> 調</li>
</ul>
</div>
</div>
</div>
<Button onClick={() => onOpenChange(false)} className="w-full">
</Button>
</div>
</DialogContent>
</Dialog>
)
}
const handleInputChange = (field: string, value: string | boolean | File | null) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] || null
handleInputChange("demoFile", file)
}
const removeFile = () => {
handleInputChange("demoFile", null)
}
const handleNext = () => {
if (step < 3) {
setStep(step + 1)
}
}
const handlePrevious = () => {
if (step > 1) {
setStep(step - 1)
}
}
const handleSubmit = async () => {
setIsSubmitting(true)
// 模擬提交過程
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsSubmitting(false)
setIsSubmitted(true)
// 3秒後關閉對話框
setTimeout(() => {
onOpenChange(false)
setIsSubmitted(false)
setStep(1)
setFormData({
name: "",
type: "文字處理",
description: "",
appUrl: "",
demoFile: null,
sourceCodeUrl: "",
documentation: "",
features: "",
technicalDetails: "",
requestFeatured: false,
agreeTerms: false,
})
}, 3000)
}
const isStep1Valid = formData.name && formData.description && formData.appUrl
const isStep2Valid = formData.features
const isStep3Valid = formData.agreeTerms
if (isSubmitted) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4"> 1-3 </p>
<div className="bg-blue-50 rounded-lg p-4 mb-4">
<div className="flex items-start space-x-3">
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="text-left">
<p className="text-sm font-medium text-blue-900"></p>
<ul className="text-sm text-blue-700 mt-1 space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
<p className="text-sm text-gray-500">...</p>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Upload className="w-5 h-5 text-blue-600" />
<span> AI </span>
</DialogTitle>
<DialogDescription> AI </DialogDescription>
</DialogHeader>
{/* Progress Indicator */}
<div className="flex items-center justify-center space-x-4 mb-6">
{[1, 2, 3].map((stepNumber) => (
<div key={stepNumber} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step >= stepNumber ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-600"
}`}
>
{stepNumber}
</div>
{stepNumber < 3 && (
<div className={`w-12 h-0.5 mx-2 ${step > stepNumber ? "bg-blue-600" : "bg-gray-200"}`} />
)}
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Form */}
<div className="lg:col-span-2">
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<FileText className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="輸入您的應用名稱"
/>
</div>
<div className="space-y-2">
<Label htmlFor="type"> *</Label>
<Select value={formData.type} onValueChange={(value) => handleInputChange("type", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"> *</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="詳細描述您的應用功能、特色和創新點..."
rows={4}
/>
<p className="text-xs text-gray-500"> 100-500 </p>
</div>
<div className="space-y-2">
<Label htmlFor="appUrl"> *</Label>
<div className="relative">
<Link className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="appUrl"
value={formData.appUrl}
onChange={(e) => handleInputChange("appUrl", e.target.value)}
placeholder="https://your-app.example.com"
className="pl-10"
/>
</div>
<p className="text-xs text-gray-500"></p>
</div>
<div className="space-y-2">
<Label htmlFor="demoFile"></Label>
<div className="space-y-3">
{!formData.demoFile ? (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-colors">
<input
type="file"
id="demoFile"
accept="video/*,.mp4,.avi,.mov,.wmv,.flv,.webm"
onChange={handleFileChange}
className="hidden"
/>
<label htmlFor="demoFile" className="cursor-pointer">
<div className="flex flex-col items-center space-y-2">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<FileVideo className="w-6 h-6 text-gray-400" />
</div>
<div>
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-xs text-gray-500"> MP4, AVI, MOV, WMV 100MB</p>
</div>
</div>
</label>
</div>
) : (
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center space-x-3">
<FileVideo className="w-5 h-5 text-blue-600" />
<div>
<p className="text-sm font-medium text-blue-900">{formData.demoFile.name}</p>
<p className="text-xs text-blue-700">
{(formData.demoFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={removeFile}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</div>
<p className="text-xs text-gray-500"></p>
</div>
</CardContent>
</Card>
)}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Zap className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="features"> *</Label>
<Textarea
id="features"
value={formData.features}
onChange={(e) => handleInputChange("features", e.target.value)}
placeholder="列出應用的主要功能和特色,例如:&#10;• 支援多語言對話&#10;• 上下文理解能力&#10;• 個性化回應..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="technicalDetails"></Label>
<Textarea
id="technicalDetails"
value={formData.technicalDetails}
onChange={(e) => handleInputChange("technicalDetails", e.target.value)}
placeholder="技術架構、使用的 AI 模型、開發框架等..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sourceCodeUrl"></Label>
<div className="relative">
<Link className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="sourceCodeUrl"
value={formData.sourceCodeUrl}
onChange={(e) => handleInputChange("sourceCodeUrl", e.target.value)}
placeholder="https://github.com/username/repo"
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="documentation"></Label>
<div className="relative">
<Link className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="documentation"
value={formData.documentation}
onChange={(e) => handleInputChange("documentation", e.target.value)}
placeholder="https://docs.your-app.com"
className="pl-10"
/>
</div>
</div>
</CardContent>
</Card>
)}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Application Summary */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium mb-3"></h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<span className="font-medium">{formData.name}</span>
</div>
<div>
<span className="text-gray-500"></span>
<Badge variant="outline">{formData.type}</Badge>
</div>
<div className="col-span-2">
<span className="text-gray-500"></span>
<span className="font-medium">
{user?.name} ({user?.department})
</span>
</div>
{formData.demoFile && (
<div className="col-span-2">
<span className="text-gray-500"></span>
<span className="font-medium">{formData.demoFile.name}</span>
</div>
)}
</div>
</div>
{/* Featured Request */}
<div className="flex items-center space-x-3 p-4 bg-yellow-50 rounded-lg">
<Switch
id="requestFeatured"
checked={formData.requestFeatured}
onCheckedChange={(checked) => handleInputChange("requestFeatured", checked)}
/>
<div className="flex-1">
<Label htmlFor="requestFeatured" className="font-medium">
</Label>
<p className="text-sm text-gray-600">
</p>
</div>
</div>
{/* Terms Agreement */}
<div className="space-y-4">
<div className="flex items-start space-x-3 p-4 border rounded-lg">
<Switch
id="agreeTerms"
checked={formData.agreeTerms}
onCheckedChange={(checked) => handleInputChange("agreeTerms", checked)}
/>
<div className="flex-1">
<Label htmlFor="agreeTerms" className="font-medium">
*
</Label>
<div className="text-sm text-gray-600 mt-2 space-y-1">
<p> </p>
<p> </p>
<p> 1-3 </p>
<p> 使</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Progress Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className={`flex items-center space-x-3 ${step >= 1 ? "text-blue-600" : "text-gray-400"}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
step >= 1 ? "bg-blue-600 text-white" : "bg-gray-200"
}`}
>
1
</div>
<span className="text-sm"></span>
</div>
<div className={`flex items-center space-x-3 ${step >= 2 ? "text-blue-600" : "text-gray-400"}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
step >= 2 ? "bg-blue-600 text-white" : "bg-gray-200"
}`}
>
2
</div>
<span className="text-sm"></span>
</div>
<div className={`flex items-center space-x-3 ${step >= 3 ? "text-blue-600" : "text-gray-400"}`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs ${
step >= 3 ? "bg-blue-600 text-white" : "bg-gray-200"
}`}
>
3
</div>
<span className="text-sm"></span>
</div>
</CardContent>
</Card>
{/* Tips Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Lightbulb className="w-4 h-4" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{step === 1 && (
<>
<div className="flex items-start space-x-2">
<Target className="w-4 h-4 text-blue-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<Target className="w-4 h-4 text-blue-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<Target className="w-4 h-4 text-blue-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<FileVideo className="w-4 h-4 text-blue-500 mt-0.5" />
<p></p>
</div>
</>
)}
{step === 2 && (
<>
<div className="flex items-start space-x-2">
<Zap className="w-4 h-4 text-green-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<Zap className="w-4 h-4 text-green-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<Zap className="w-4 h-4 text-green-500 mt-0.5" />
<p></p>
</div>
</>
)}
{step === 3 && (
<>
<div className="flex items-start space-x-2">
<CheckCircle className="w-4 h-4 text-purple-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<CheckCircle className="w-4 h-4 text-purple-500 mt-0.5" />
<p></p>
</div>
<div className="flex items-start space-x-2">
<CheckCircle className="w-4 h-4 text-purple-500 mt-0.5" />
<p></p>
</div>
</>
)}
</CardContent>
</Card>
{/* Review Process Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center space-x-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
<Upload className="w-3 h-3 text-blue-600" />
</div>
<span></span>
</div>
<div className="flex items-center space-x-3">
<div className="w-6 h-6 bg-yellow-100 rounded-full flex items-center justify-center">
<Clock className="w-3 h-3 text-yellow-600" />
</div>
<span></span>
</div>
<div className="flex items-center space-x-3">
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-3 h-3 text-green-600" />
</div>
<span></span>
</div>
<Separator />
<div className="bg-blue-50 p-3 rounded-lg">
<p className="text-xs text-blue-700">
<strong></strong>1-3
<br />
<strong></strong>
</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-between pt-6 border-t">
<Button variant="outline" onClick={handlePrevious} disabled={step === 1}>
</Button>
<div className="flex space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
{step < 3 ? (
<Button
onClick={handleNext}
disabled={(step === 1 && !isStep1Valid) || (step === 2 && !isStep2Valid)}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!isStep3Valid || isSubmitting}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700"
>
{isSubmitting ? (
<>
<Clock className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,168 @@
"use client"
import { useAuth } from "@/contexts/auth-context"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Progress } from "@/components/ui/progress"
import { BarChart3, Clock, Heart, ImageIcon, MessageSquare, FileText, TrendingUp } from "lucide-react"
interface ActivityRecordsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
// Recent apps data - empty for production
const recentApps: any[] = []
// Category data - empty for production
const categoryData: any[] = []
export function ActivityRecordsDialog({ open, onOpenChange }: ActivityRecordsDialogProps) {
const { user } = useAuth()
if (!user) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[85vh] overflow-hidden">
<DialogHeader>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="w-6 h-6" />
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="recent" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="recent">使</TabsTrigger>
<TabsTrigger value="statistics"></TabsTrigger>
</TabsList>
<TabsContent value="recent" className="space-y-4 max-h-[60vh] overflow-y-auto">
<div>
<h3 className="text-lg font-semibold mb-2">使</h3>
<p className="text-sm text-muted-foreground mb-4"> AI </p>
<div className="grid gap-4">
{recentApps.map((app) => {
const IconComponent = app.icon
return (
<Card key={app.id} className="hover:shadow-md transition-shadow">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center space-x-4">
<div className={`p-3 rounded-lg ${app.color}`}>
<IconComponent className="w-6 h-6 text-white" />
</div>
<div>
<h4 className="font-semibold">{app.name}</h4>
<p className="text-sm text-muted-foreground">by {app.author}</p>
<div className="flex items-center gap-4 mt-1">
<Badge variant="secondary" className="text-xs">
{app.category}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
使 {app.usageCount}
</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{app.timeSpent}
</span>
</div>
</div>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground mb-2">{app.lastUsed}</p>
<Button size="sm" variant="outline">
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</TabsContent>
<TabsContent value="statistics" className="space-y-6 max-h-[60vh] overflow-y-auto">
<div>
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4"></p>
{/* Statistics Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">41</div>
<p className="text-xs text-muted-foreground"> 12%</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">使</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">9.2</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Heart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">6</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">0</div>
</CardContent>
</Card>
</div>
{/* Category Usage */}
<Card>
<CardHeader>
<CardTitle className="text-base">使</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{categoryData.map((category, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${category.color}`} />
<span className="font-medium">{category.name}</span>
</div>
<span className="text-muted-foreground">{category.usage}%</span>
</div>
<Progress value={category.usage} className="h-2" />
</div>
))}
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Mail, ArrowLeft, CheckCircle } from "lucide-react"
interface ForgotPasswordDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onBackToLogin: () => void
}
export function ForgotPasswordDialog({ open, onOpenChange, onBackToLogin }: ForgotPasswordDialogProps) {
const [email, setEmail] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!email) {
setError("請輸入電子郵件地址")
return
}
if (!email.includes("@")) {
setError("請輸入有效的電子郵件地址")
return
}
setIsLoading(true)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsLoading(false)
setIsSuccess(true)
}
const handleClose = () => {
setEmail("")
setError("")
setIsSuccess(false)
onOpenChange(false)
}
const handleResend = async () => {
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setIsLoading(false)
}
if (isSuccess) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex flex-col items-center space-y-4">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<DialogTitle className="text-2xl font-bold text-center"></DialogTitle>
<DialogDescription className="text-center"></DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-800 mb-2"></p>
<p className="text-sm text-blue-700 font-medium">{email}</p>
</div>
<div className="text-sm text-gray-600 space-y-2">
<p> </p>
<p> 24 </p>
<p> </p>
</div>
<div className="flex space-x-3">
<Button onClick={onBackToLogin} variant="outline" className="flex-1 bg-transparent">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleResend} disabled={isLoading} className="flex-1">
{isLoading ? "發送中..." : "重新發送"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-center"></DialogTitle>
<DialogDescription className="text-center">
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reset-email"></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="reset-email"
type="email"
placeholder="請輸入您的電子郵件"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex space-x-3">
<Button type="button" onClick={onBackToLogin} variant="outline" className="flex-1 bg-transparent">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button type="submit" disabled={isLoading} className="flex-1">
{isLoading ? "發送中..." : "發送重設連結"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,134 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Eye, EyeOff, Mail, Lock } from "lucide-react"
interface LoginDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSwitchToRegister: () => void
onSwitchToForgotPassword: () => void
}
export function LoginDialog({ open, onOpenChange, onSwitchToRegister, onSwitchToForgotPassword }: LoginDialogProps) {
const { login, isLoading } = useAuth()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (!email || !password) {
setError("請填寫所有欄位")
return
}
const success = await login(email, password)
if (success) {
onOpenChange(false)
setEmail("")
setPassword("")
} else {
setError("登入失敗,請檢查您的電子郵件和密碼")
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-center"></DialogTitle>
<DialogDescription className="text-center"> AI </DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="email"
type="email"
placeholder="請輸入您的電子郵件"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="請輸入您的密碼"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onSwitchToForgotPassword}
className="text-sm text-blue-600 hover:text-blue-800"
>
</button>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-2"></p>
<p className="text-sm">Email: zhang@panjit.com</p>
<p className="text-sm">Password: password123</p>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "登入中..." : "登入"}
</Button>
<div className="text-center">
<span className="text-sm text-gray-600"></span>
<button
type="button"
onClick={onSwitchToRegister}
className="text-sm text-blue-600 hover:text-blue-800 ml-1"
>
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,193 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { CheckCircle, AlertTriangle, Loader2, User, Camera } from "lucide-react"
interface ProfileDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
const { user, updateProfile } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [profileData, setProfileData] = useState({
name: user?.name || "",
email: user?.email || "",
department: user?.department || "",
bio: user?.bio || "",
phone: user?.phone || "",
location: user?.location || "",
})
const departments = ["HQBU", "ITBU", "MBU1", "MBU2", "SBU", "財務部", "人資部", "法務部"]
const handleSave = async () => {
setError("")
setSuccess("")
setIsLoading(true)
try {
await updateProfile(profileData)
setSuccess("個人資料更新成功!")
setTimeout(() => setSuccess(""), 3000)
} catch (err) {
setError("更新失敗,請稍後再試")
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<User className="w-5 h-5" />
<span></span>
</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
<div className="flex items-center space-x-6">
<div className="relative">
<Avatar className="w-24 h-24">
<AvatarImage src={user?.avatar || "/placeholder.svg"} />
<AvatarFallback className="text-2xl bg-gradient-to-r from-blue-600 to-purple-600 text-white">
{user?.name?.charAt(0) || "U"}
</AvatarFallback>
</Avatar>
<Button
size="sm"
variant="outline"
className="absolute -bottom-2 -right-2 rounded-full w-8 h-8 p-0 bg-transparent"
>
<Camera className="w-4 h-4" />
</Button>
</div>
<div>
<h3 className="text-xl font-semibold">{user?.name}</h3>
<p className="text-gray-600">{user?.email}</p>
<Badge variant="outline" className="mt-2">
{user?.department}
</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={profileData.name}
onChange={(e) => setProfileData({ ...profileData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={profileData.email}
onChange={(e) => setProfileData({ ...profileData, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Select
value={profileData.department}
onValueChange={(value) => setProfileData({ ...profileData, department: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇部門" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
value={profileData.phone}
onChange={(e) => setProfileData({ ...profileData, phone: e.target.value })}
placeholder="輸入電話號碼"
/>
</div>
<div className="space-y-2">
<Label htmlFor="location"></Label>
<Input
id="location"
value={profileData.location}
onChange={(e) => setProfileData({ ...profileData, location: e.target.value })}
placeholder="輸入工作地點"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="bio"></Label>
<Input
id="bio"
value={profileData.bio}
onChange={(e) => setProfileData({ ...profileData, bio: e.target.value })}
placeholder="簡單介紹一下自己..."
/>
</div>
</div>
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"儲存變更"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,183 @@
"use client"
import type React from "react"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { CheckCircle, AlertTriangle, Loader2 } from "lucide-react"
interface RegisterDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
const { register } = useAuth()
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
department: "",
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const departments = ["HQBU", "ITBU", "MBU1", "MBU2", "SBU", "財務部", "人資部", "法務部"]
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
if (formData.password !== formData.confirmPassword) {
setError("密碼確認不符")
return
}
if (formData.password.length < 6) {
setError("密碼至少需要6個字符")
return
}
setIsLoading(true)
try {
await register({
name: formData.name,
email: formData.email,
password: formData.password,
department: formData.department,
})
setSuccess(true)
setTimeout(() => {
setSuccess(false)
onOpenChange(false)
setFormData({
name: "",
email: "",
password: "",
confirmPassword: "",
department: "",
})
}, 2000)
} catch (err) {
setError("註冊失敗,請稍後再試")
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{success ? (
<div className="text-center py-6">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-green-700 mb-2"></h3>
<p className="text-gray-600"></p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Select
value={formData.department}
onValueChange={(value) => setFormData({ ...formData, department: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇部門" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"註冊"
)}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,125 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Loader2, Settings, Palette } from "lucide-react"
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const [settings, setSettings] = useState({
language: "zh-TW",
theme: "system",
})
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState("")
const handleSave = async () => {
setIsLoading(true)
setSuccess("")
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1500))
setSuccess("設定已儲存成功!")
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
const updateSetting = (key: string, value: any) => {
setSettings((prev) => ({
...prev,
[key]: value,
}))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
<Settings className="w-6 h-6" />
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{success && (
<Alert className="border-green-200 bg-green-50">
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{/* Interface Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="w-5 h-5" />
</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="language"></Label>
<Select value={settings.language} onValueChange={(value) => updateSetting("language", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-TW"></SelectItem>
<SelectItem value="zh-CN"></SelectItem>
<SelectItem value="en">English</SelectItem>
<SelectItem value="ja"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="theme"></Label>
<Select value={settings.theme} onValueChange={(value) => updateSetting("theme", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light"></SelectItem>
<SelectItem value="dark"></SelectItem>
<SelectItem value="system"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={isLoading}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"儲存設定"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,196 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { User, BarChart3, Settings, LogOut, Code, Shield, Upload } from "lucide-react"
import { LoginDialog } from "./login-dialog"
import { RegisterDialog } from "./register-dialog"
import { ForgotPasswordDialog } from "./forgot-password-dialog"
import { ProfileDialog } from "./profile-dialog"
import { ActivityRecordsDialog } from "./activity-records-dialog"
import { SettingsDialog } from "./settings-dialog"
import { AppSubmissionDialog } from "../app-submission-dialog"
export function UserMenu() {
const { user, logout, canAccessAdmin, canSubmitApp } = useAuth()
const [loginOpen, setLoginOpen] = useState(false)
const [registerOpen, setRegisterOpen] = useState(false)
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
const [activityOpen, setActivityOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [showAppSubmission, setShowAppSubmission] = useState(false)
const handleSwitchToRegister = () => {
setLoginOpen(false)
setRegisterOpen(true)
}
const handleSwitchToLogin = () => {
setRegisterOpen(false)
setForgotPasswordOpen(false)
setLoginOpen(true)
}
const handleSwitchToForgotPassword = () => {
setLoginOpen(false)
setForgotPasswordOpen(true)
}
const getRoleText = (role: string) => {
switch (role) {
case "admin":
return "管理員"
case "developer":
return "開發者"
case "user":
return "一般用戶"
default:
return role
}
}
const getRoleIcon = (role: string) => {
switch (role) {
case "admin":
return <Shield className="w-3 h-3" />
case "developer":
return <Code className="w-3 h-3" />
case "user":
return <User className="w-3 h-3" />
default:
return <User className="w-3 h-3" />
}
}
const getRoleColor = (role: string) => {
switch (role) {
case "admin":
return "bg-purple-100 text-purple-800"
case "developer":
return "bg-green-100 text-green-800"
case "user":
return "bg-blue-100 text-blue-800"
default:
return "bg-gray-100 text-gray-800"
}
}
if (!user) {
return (
<>
<Button
onClick={() => setLoginOpen(true)}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white"
>
</Button>
<LoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
onSwitchToRegister={handleSwitchToRegister}
onSwitchToForgotPassword={handleSwitchToForgotPassword}
/>
<RegisterDialog open={registerOpen} onOpenChange={setRegisterOpen} onSwitchToLogin={handleSwitchToLogin} />
<ForgotPasswordDialog
open={forgotPasswordOpen}
onOpenChange={setForgotPasswordOpen}
onBackToLogin={handleSwitchToLogin}
/>
</>
)
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10">
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-4">
<Avatar className="h-12 w-12">
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-lg">
{user.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col space-y-1 leading-none">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
<div className="flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{user.department}
</span>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${getRoleColor(user.role)}`}
>
{getRoleIcon(user.role)}
<span className="ml-1">{getRoleText(user.role)}</span>
</span>
</div>
</div>
</div>
<DropdownMenuSeparator />
{canAccessAdmin() && (
<>
<DropdownMenuItem onClick={() => window.open("/admin", "_blank")}>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{canSubmitApp() && (
<>
<DropdownMenuItem onClick={() => setShowAppSubmission(true)}>
<Upload className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => setProfileOpen(true)}>
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setActivityOpen(true)}>
<BarChart3 className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProfileDialog open={profileOpen} onOpenChange={setProfileOpen} />
<ActivityRecordsDialog open={activityOpen} onOpenChange={setActivityOpen} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<AppSubmissionDialog open={showAppSubmission} onOpenChange={setShowAppSubmission} />
</>
)
}

397
components/chat-bot.tsx Normal file
View File

@@ -0,0 +1,397 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
MessageCircle,
X,
Send,
Bot,
User,
Loader2
} from "lucide-react"
interface Message {
id: string
text: string
sender: "user" | "bot"
timestamp: Date
quickQuestions?: string[]
}
const DEEPSEEK_API_KEY = process.env.NEXT_PUBLIC_DEEPSEEK_API_KEY || "sk-3640dcff23fe4a069a64f536ac538d75"
const DEEPSEEK_API_URL = process.env.NEXT_PUBLIC_DEEPSEEK_API_URL || "https://api.deepseek.com/v1/chat/completions"
const systemPrompt = `你是一個競賽管理系統的AI助手專門幫助用戶了解如何使用這個系統。
系統功能包括:
後台管理功能:
1. 競賽管理 - 創建、編輯、刪除競賽
2. 評審管理 - 管理評審團成員
3. 評分系統 - 手動輸入評分或讓評審自行評分
4. 團隊管理 - 管理參賽團隊
5. 獎項管理 - 設定各種獎項
6. 評審連結 - 提供評審登入連結
前台功能:
1. 競賽瀏覽 - 查看所有競賽資訊和詳細內容
2. 團隊註冊 - 如何註冊參賽團隊和提交作品
3. 作品展示 - 瀏覽參賽作品和投票功能
4. 排行榜 - 查看人氣排行榜和得獎名單
5. 個人中心 - 管理個人資料和參賽記錄
6. 收藏功能 - 如何收藏喜歡的作品
7. 評論系統 - 如何對作品進行評論和互動
8. 搜尋功能 - 如何搜尋特定競賽或作品
9. 通知系統 - 查看競賽更新和個人通知
10. 幫助中心 - 常見問題和使用指南
請用友善、專業的語氣回答用戶問題,並提供具體的操作步驟。回答要簡潔明瞭,避免過長的文字。
重要請不要使用任何Markdown格式只使用純文字回答。不要使用**、*、#、-等符號。
回答時請使用繁體中文。`
export function ChatBot() {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<Message[]>([
{
id: "1",
text: "您好我是AI助手很高興為您服務。我可以協助您了解競賽管理系統的使用方法包括後台管理和前台功能。請問有什麼可以幫助您的嗎",
sender: "bot",
timestamp: new Date(),
quickQuestions: [
"如何註冊參賽團隊?",
"怎麼提交作品?",
"如何創建新競賽?",
"怎麼管理評審團?"
]
}
])
const [inputValue, setInputValue] = useState("")
const [isTyping, setIsTyping] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
useEffect(() => {
scrollToBottom()
}, [messages])
// 清理 Markdown 格式和過長文字
const cleanResponse = (text: string): string => {
return text
// 移除 Markdown 格式
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`(.*?)`/g, '$1')
.replace(/#{1,6}\s/g, '')
.replace(/^- /g, '• ')
.replace(/^\d+\.\s/g, '')
// 移除多餘的空行
.replace(/\n\s*\n\s*\n/g, '\n\n')
// 限制文字長度,如果太長就截斷並添加省略號
.slice(0, 300)
.trim()
}
const callDeepSeekAPI = async (userMessage: string): Promise<string> => {
try {
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content: systemPrompt
},
...messages
.filter(msg => msg.sender === "user")
.map(msg => ({
role: "user" as const,
content: msg.text
})),
{
role: "user",
content: userMessage
}
],
max_tokens: 200, // 減少 token 數量以獲得更簡潔的回答
temperature: 0.7
})
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`)
}
const data = await response.json()
const rawResponse = data.choices[0]?.message?.content || "抱歉,我現在無法回答您的問題,請稍後再試。"
return cleanResponse(rawResponse)
} catch (error) {
console.error("DeepSeek API error:", error)
return "抱歉我現在無法連接到AI服務請檢查網路連接或稍後再試。"
}
}
// 根據用戶問題生成相關的快速問題
const generateQuickQuestions = (userQuestion: string): string[] => {
const question = userQuestion.toLowerCase()
// 前台相關問題
if (question.includes('註冊') || question.includes('團隊')) {
return [
"如何提交作品?",
"怎麼查看競賽詳情?",
"如何收藏作品?",
"怎麼進行投票?"
]
}
if (question.includes('作品') || question.includes('提交')) {
return [
"如何修改作品?",
"怎麼查看作品狀態?",
"如何刪除作品?",
"怎麼下載作品?"
]
}
if (question.includes('投票') || question.includes('排行榜')) {
return [
"如何查看排行榜?",
"怎麼收藏作品?",
"如何評論作品?",
"怎麼分享作品?"
]
}
// 後台相關問題
if (question.includes('競賽') || question.includes('創建')) {
return [
"如何編輯競賽?",
"怎麼設定評分標準?",
"如何管理參賽團隊?",
"怎麼設定獎項?"
]
}
if (question.includes('評審') || question.includes('評分')) {
return [
"如何新增評審?",
"怎麼設定評審權限?",
"如何查看評分結果?",
"怎麼生成評審連結?"
]
}
// 通用問題
return [
"如何註冊參賽團隊?",
"怎麼提交作品?",
"如何創建新競賽?",
"怎麼管理評審團?"
]
}
const handleSendMessage = async (text: string) => {
if (!text.trim() || isLoading) return
const userMessage: Message = {
id: Date.now().toString(),
text: text.trim(),
sender: "user",
timestamp: new Date()
}
setMessages(prev => [...prev, userMessage])
setInputValue("")
setIsTyping(true)
setIsLoading(true)
try {
const aiResponse = await callDeepSeekAPI(text)
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: aiResponse,
sender: "bot",
timestamp: new Date(),
quickQuestions: generateQuickQuestions(text)
}
setMessages(prev => [...prev, botMessage])
} catch (error) {
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
text: "抱歉,發生錯誤,請稍後再試。",
sender: "bot",
timestamp: new Date()
}
setMessages(prev => [...prev, errorMessage])
} finally {
setIsTyping(false)
setIsLoading(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
return (
<>
{/* 浮動按鈕 */}
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 hover:bg-blue-700 shadow-lg z-50"
size="icon"
>
<MessageCircle className="w-6 h-6" />
</Button>
{/* 聊天對話框 */}
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-end justify-end p-4">
<Card className="w-96 max-h-[80vh] flex flex-col shadow-2xl">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="flex items-center space-x-2">
<Bot className="w-5 h-5 text-blue-600" />
<span>AI </span>
<Badge variant="secondary" className="text-xs"></Badge>
</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(false)}
className="h-8 w-8"
>
<X className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 min-h-0">
{/* 訊息區域 */}
<div className="flex-1 overflow-y-auto px-4" style={{ minHeight: '200px', maxHeight: 'calc(80vh - 200px)' }}>
<div className="space-y-4 pb-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div className={`flex items-start space-x-2 max-w-[85%] ${message.sender === "user" ? "flex-row-reverse space-x-reverse" : ""}`}>
<Avatar className="w-8 h-8 flex-shrink-0">
<AvatarFallback className="text-xs">
{message.sender === "user" ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
</AvatarFallback>
</Avatar>
<div
className={`rounded-lg px-3 py-2 text-sm break-words whitespace-pre-wrap ${
message.sender === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
style={{
maxWidth: '100%',
wordWrap: 'break-word',
overflowWrap: 'break-word'
}}
>
{message.text}
{/* 快速問題按鈕 */}
{message.sender === "bot" && message.quickQuestions && message.quickQuestions.length > 0 && (
<div className="mt-3 space-y-2">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{message.quickQuestions.map((question, index) => (
<Button
key={index}
variant="outline"
size="sm"
className="text-xs h-7 px-2 py-1 bg-white hover:bg-gray-50 border-gray-200"
onClick={() => handleSendMessage(question)}
disabled={isLoading}
>
{question}
</Button>
))}
</div>
</div>
)}
</div>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="flex items-start space-x-2">
<Avatar className="w-8 h-8 flex-shrink-0">
<AvatarFallback className="text-xs">
<Bot className="w-4 h-4" />
</AvatarFallback>
</Avatar>
<div className="bg-gray-100 rounded-lg px-3 py-2">
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-gray-600">AI ...</span>
</div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* 輸入區域 */}
<div className="p-4 border-t border-gray-200 bg-white flex-shrink-0">
<div className="flex items-center space-x-2">
<div className="flex-1 min-w-0">
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="輸入您的問題..."
className="w-full"
disabled={isLoading}
/>
</div>
<Button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim() || isLoading}
size="icon"
className="w-10 h-10 flex-shrink-0"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,661 @@
"use client"
import { useState } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Progress } from "@/components/ui/progress"
import {
Target,
Users,
Lightbulb,
Trophy,
Crown,
Award,
Camera,
ImageIcon,
ChevronLeft,
ChevronRight,
X,
Star,
MessageSquare,
BarChart3,
ExternalLink,
Eye,
Link,
FileText,
} from "lucide-react"
import type { Award as AwardType } from "@/types/competition"
interface AwardDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
award: AwardType
}
// Judge scoring data - empty for production
const getJudgeScores = (awardId: string) => {
return []
}
// App links and reports data - empty for production
const getAppData = (awardId: string) => {
return {
appUrl: "",
demoUrl: "",
githubUrl: "",
reports: [],
}
}
export function AwardDetailDialog({ open, onOpenChange, award }: AwardDetailDialogProps) {
const { competitions, judges, getTeamById, getProposalById } = useCompetition()
const [activeTab, setActiveTab] = useState("overview")
const [showPhotoGallery, setShowPhotoGallery] = useState(false)
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0)
const competition = competitions.find((c) => c.id === award.competitionId)
const judgeScores = getJudgeScores(award.id)
const appData = getAppData(award.id)
// Competition photos - empty for production
const getCompetitionPhotos = () => {
return []
}
const competitionPhotos = getCompetitionPhotos()
const getCompetitionTypeIcon = (type: string) => {
switch (type) {
case "individual":
return <Target className="w-4 h-4" />
case "team":
return <Users className="w-4 h-4" />
case "proposal":
return <Lightbulb className="w-4 h-4" />
default:
return <Trophy className="w-4 h-4" />
}
}
const getCompetitionTypeText = (type: string) => {
switch (type) {
case "individual":
return "個人賽"
case "team":
return "團隊賽"
case "proposal":
return "提案賽"
default:
return "競賽"
}
}
const getCompetitionTypeColor = (type: string) => {
switch (type) {
case "individual":
return "bg-blue-100 text-blue-800 border-blue-200"
case "team":
return "bg-green-100 text-green-800 border-green-200"
case "proposal":
return "bg-purple-100 text-purple-800 border-purple-200"
default:
return "bg-gray-100 text-gray-800 border-gray-200"
}
}
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case "pdf":
return <FileText className="w-5 h-5 text-red-500" />
case "pptx":
case "ppt":
return <FileText className="w-5 h-5 text-orange-500" />
case "docx":
case "doc":
return <FileText className="w-5 h-5 text-blue-500" />
default:
return <FileText className="w-5 h-5 text-gray-500" />
}
}
const nextPhoto = () => {
setCurrentPhotoIndex((prev) => (prev + 1) % competitionPhotos.length)
}
const prevPhoto = () => {
setCurrentPhotoIndex((prev) => (prev - 1 + competitionPhotos.length) % competitionPhotos.length)
}
const handlePreview = (report: any) => {
// Open preview in new window
window.open(report.previewUrl, "_blank")
}
const renderAwardOverview = () => (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center space-x-4">
<div className="text-6xl">{award.icon}</div>
<div className="flex-1">
<CardTitle className="text-2xl">{award.awardName}</CardTitle>
<CardDescription className="text-lg">
{award.appName || award.proposalTitle || award.teamName}
</CardDescription>
<div className="flex items-center space-x-4 mt-2">
<Badge variant="outline" className={getCompetitionTypeColor(award.competitionType)}>
{getCompetitionTypeIcon(award.competitionType)}
<span className="ml-1">{getCompetitionTypeText(award.competitionType)}</span>
</Badge>
<Badge
variant="secondary"
className={`${
award.awardType === "gold"
? "bg-yellow-100 text-yellow-800 border-yellow-200"
: award.awardType === "silver"
? "bg-gray-100 text-gray-800 border-gray-200"
: award.awardType === "bronze"
? "bg-orange-100 text-orange-800 border-orange-200"
: "bg-red-100 text-red-800 border-red-200"
}`}
>
{award.awardName}
</Badge>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-6 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600 mb-1">
{award.awardType === "popular" && award.competitionType === "team"
? `${award.score}`
: award.awardType === "popular"
? `${award.score}`
: award.score}
</div>
<div className="text-sm text-gray-500">
{award.competitionType === "proposal"
? "評審評分"
: award.awardType === "popular"
? award.competitionType === "team"
? "人氣指數"
: "收藏數"
: "評審評分"}
</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600 mb-1">
{award.year}{award.month}
</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600 mb-1">{award.creator}</div>
<div className="text-sm text-gray-500">
{award.competitionType === "team"
? "團隊"
: award.competitionType === "proposal"
? "提案團隊"
: "創作者"}
</div>
</div>
</div>
{competition && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2 flex items-center">
<Trophy className="w-5 h-5 mr-2" />
</h4>
<div className="text-blue-800">
<p className="mb-2">
<strong></strong>
{competition.name}
</p>
<p className="mb-2">
<strong></strong>
{competition.description}
</p>
<p>
<strong></strong>
{competition.startDate} ~ {competition.endDate}
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* App Links Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Link className="w-5 h-5 text-green-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{appData.appUrl && (
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center space-x-3">
<ExternalLink className="w-5 h-5 text-green-600" />
<div>
<p className="font-medium text-green-800"></p>
<p className="text-xs text-green-600"></p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="border-green-300 text-green-700 hover:bg-green-100 bg-transparent"
onClick={() => window.open(appData.appUrl, "_blank")}
>
</Button>
</div>
)}
{appData.demoUrl && (
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center space-x-3">
<Eye className="w-5 h-5 text-blue-600" />
<div>
<p className="font-medium text-blue-800"></p>
<p className="text-xs text-blue-600"></p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="border-blue-300 text-blue-700 hover:bg-blue-100 bg-transparent"
onClick={() => window.open(appData.demoUrl, "_blank")}
>
</Button>
</div>
)}
{appData.githubUrl && (
<div className="flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg">
<div className="flex items-center space-x-3">
<FileText className="w-5 h-5 text-gray-600" />
<div>
<p className="font-medium text-gray-800"></p>
<p className="text-xs text-gray-600">GitHub</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-100 bg-transparent"
onClick={() => window.open(appData.githubUrl, "_blank")}
>
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Reports Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<FileText className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{appData.reports.map((report) => (
<div
key={report.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center space-x-4">
{getFileIcon(report.type)}
<div className="flex-1">
<h4 className="font-medium text-gray-900">{report.name}</h4>
<p className="text-sm text-gray-600">{report.description}</p>
<div className="flex items-center space-x-4 mt-1 text-xs text-gray-500">
<span>{report.size}</span>
<span>{report.uploadDate}</span>
<span className="uppercase">{report.type}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => handlePreview(report)}
className="text-blue-600 border-blue-300 hover:bg-blue-50"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
const renderCompetitionPhotos = () => (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Camera className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{competitionPhotos.map((photo, index) => (
<div
key={index}
className="relative group cursor-pointer overflow-hidden rounded-lg border hover:shadow-lg transition-all"
onClick={() => {
setCurrentPhotoIndex(index)
setShowPhotoGallery(true)
}}
>
<img
src={photo.url || "/placeholder.svg"}
alt={photo.title}
className="w-full h-32 object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-2">
<p className="text-white text-xs font-medium">{photo.title}</p>
</div>
</div>
))}
</div>
<div className="mt-4 text-center">
<Button
variant="outline"
onClick={() => {
setCurrentPhotoIndex(0)
setShowPhotoGallery(true)
}}
>
<Camera className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
)
const renderJudgePanel = () => {
if (!competition) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Crown className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{competition.judges.map((judgeId) => {
const judge = judges.find((j) => j.id === judgeId)
if (!judge) return null
return (
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<Avatar>
<AvatarImage src={judge.avatar || "/placeholder.svg?height=40&width=40"} />
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-medium">{judge.name}</h4>
<p className="text-sm text-gray-600">{judge.title}</p>
<div className="flex flex-wrap gap-1 mt-1">
{judge.expertise.slice(0, 2).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
const renderJudgeScores = () => (
<div className="space-y-6">
{/* Overall Statistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="w-5 h-5 text-blue-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{(judgeScores.reduce((sum, score) => sum + score.overallScore, 0) / judgeScores.length).toFixed(1)}
</div>
<div className="text-sm text-blue-600"></div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{Math.max(...judgeScores.map((s) => s.overallScore)).toFixed(1)}
</div>
<div className="text-sm text-green-600"></div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{Math.min(...judgeScores.map((s) => s.overallScore)).toFixed(1)}
</div>
<div className="text-sm text-orange-600"></div>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{judgeScores.length}</div>
<div className="text-sm text-purple-600"></div>
</div>
</div>
</CardContent>
</Card>
{/* Individual Judge Scores */}
{judgeScores.map((judgeScore, index) => (
<Card key={judgeScore.judgeId}>
<CardHeader>
<div className="flex items-center space-x-4">
<Avatar className="w-12 h-12">
<AvatarImage src={judgeScore.judgeAvatar || "/placeholder.svg"} />
<AvatarFallback className="bg-blue-100 text-blue-700">{judgeScore.judgeName[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<CardTitle className="text-lg">{judgeScore.judgeName}</CardTitle>
<CardDescription>{judgeScore.judgeTitle}</CardDescription>
</div>
<div className="text-right">
<div className="flex items-center space-x-1">
<Star className="w-5 h-5 text-yellow-500" />
<span className="text-2xl font-bold text-gray-900">{judgeScore.overallScore}</span>
<span className="text-gray-500">/5.0</span>
</div>
<div className="text-xs text-gray-500 mt-1">{judgeScore.submittedAt}</div>
</div>
</div>
</CardHeader>
<CardContent>
{/* Criteria Scores */}
<div className="space-y-4 mb-6">
<h4 className="font-semibold text-gray-900 flex items-center">
<BarChart3 className="w-4 h-4 mr-2" />
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{judgeScore.criteria.map((criterion, criterionIndex) => (
<div key={criterionIndex} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{criterion.name}</span>
<span className="text-sm text-gray-600">
{criterion.score}/{criterion.maxScore}
</span>
</div>
<Progress value={(criterion.score / criterion.maxScore) * 100} className="h-2" />
</div>
))}
</div>
</div>
{/* Judge Comment */}
<div className="space-y-3">
<h4 className="font-semibold text-gray-900 flex items-center">
<MessageSquare className="w-4 h-4 mr-2" />
</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-gray-700 leading-relaxed">{judgeScore.comment}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl flex items-center space-x-2">
<Award className="w-6 h-6 text-purple-500" />
<span></span>
</DialogTitle>
<DialogDescription>
{competition?.name} - {award.awardName}
</DialogDescription>
</DialogHeader>
<div className="mt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="photos"></TabsTrigger>
<TabsTrigger value="judges"></TabsTrigger>
<TabsTrigger value="scores"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
{renderAwardOverview()}
</TabsContent>
<TabsContent value="photos" className="space-y-6">
{renderCompetitionPhotos()}
</TabsContent>
<TabsContent value="judges" className="space-y-6">
{renderJudgePanel()}
</TabsContent>
<TabsContent value="scores" className="space-y-6">
{renderJudgeScores()}
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
{/* Photo Gallery Modal */}
<Dialog open={showPhotoGallery} onOpenChange={setShowPhotoGallery}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<div className="relative">
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 z-10 bg-black bg-opacity-50 text-white hover:bg-opacity-70"
onClick={() => setShowPhotoGallery(false)}
>
<X className="w-4 h-4" />
</Button>
<div className="relative">
<img
src={competitionPhotos[currentPhotoIndex]?.url || "/placeholder.svg"}
alt={competitionPhotos[currentPhotoIndex]?.title}
className="w-full h-96 object-cover"
/>
<Button
variant="ghost"
size="sm"
className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 text-white hover:bg-opacity-70"
onClick={prevPhoto}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 text-white hover:bg-opacity-70"
onClick={nextPhoto}
>
<ChevronRight className="w-4 h-4" />
</Button>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4">
<h3 className="text-white text-lg font-semibold">{competitionPhotos[currentPhotoIndex]?.title}</h3>
<p className="text-white text-sm opacity-90">{competitionPhotos[currentPhotoIndex]?.description}</p>
</div>
</div>
<div className="p-4">
<div className="flex items-center justify-center space-x-2">
{competitionPhotos.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-colors ${
index === currentPhotoIndex ? "bg-purple-600" : "bg-gray-300"
}`}
onClick={() => setCurrentPhotoIndex(index)}
/>
))}
</div>
<div className="text-center text-sm text-gray-500 mt-2">
{currentPhotoIndex + 1} / {competitionPhotos.length}
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,731 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useCompetition } from "@/contexts/competition-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
import {
Target,
Users,
Lightbulb,
Star,
Heart,
Eye,
Trophy,
Crown,
UserCheck,
Building,
Mail,
Clock,
CheckCircle,
AlertCircle,
MessageSquare,
ImageIcon,
Mic,
TrendingUp,
Brain,
Zap,
Play,
} from "lucide-react"
// AI applications data - empty for production
const aiApps: any[] = []
interface CompetitionDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
ranking: any
competitionType: "individual" | "team" | "proposal"
}
export function CompetitionDetailDialog({
open,
onOpenChange,
ranking,
competitionType,
}: CompetitionDetailDialogProps) {
const { user, getAppLikes, getViewCount, getAppRating, incrementViewCount, addToRecentApps } = useAuth()
const { judges, getAppJudgeScores, getProposalJudgeScores, getTeamById, getProposalById, currentCompetition } =
useCompetition()
const [activeTab, setActiveTab] = useState("overview")
const handleTryApp = (appId: string) => {
incrementViewCount(appId)
addToRecentApps(appId)
console.log(`Opening app ${appId}`)
}
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const renderIndividualDetail = () => {
const app = aiApps.find((a) => a.id === ranking.appId)
const judgeScores = getAppJudgeScores(ranking.appId!)
const likes = getAppLikes(ranking.appId!)
const views = getViewCount(ranking.appId!)
const rating = getAppRating(ranking.appId!)
if (!app) return null
const IconComponent = app.icon
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="scores"></TabsTrigger>
<TabsTrigger value="experience"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center">
<IconComponent className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{app.name}</CardTitle>
<CardDescription className="text-lg">by {app.creator}</CardDescription>
<div className="flex items-center space-x-4 mt-2">
<Badge variant="outline" className={getTypeColor(app.type)}>
{app.type}
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge>
<div className="flex items-center space-x-1 text-2xl font-bold text-purple-600">
<Trophy className="w-6 h-6" />
<span> {ranking.rank} </span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-gray-700 mb-6">{app.description}</p>
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-purple-600">
<Star className="w-6 h-6" />
<span>{ranking.totalScore.toFixed(1)}</span>
</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-red-600">
<Heart className="w-6 h-6" />
<span>{likes}</span>
</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-blue-600">
<Eye className="w-6 h-6" />
<span>{views}</span>
</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-yellow-600">
<Star className="w-6 h-6" />
<span>{rating.toFixed(1)}</span>
</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
</div>
<div className="grid grid-cols-5 gap-4">
{[
{ key: "innovation", name: "創新性", icon: "💡", color: "text-yellow-600" },
{ key: "technical", name: "技術性", icon: "⚙️", color: "text-blue-600" },
{ key: "usability", name: "實用性", icon: "🎯", color: "text-green-600" },
{ key: "presentation", name: "展示效果", icon: "🎨", color: "text-purple-600" },
{ key: "impact", name: "影響力", icon: "🚀", color: "text-red-600" },
].map((category) => (
<div key={category.key} className="text-center p-4 bg-white border rounded-lg">
<div className="text-2xl mb-2">{category.icon}</div>
<div className={`text-xl font-bold ${category.color}`}>
{ranking.scores[category.key as keyof typeof ranking.scores].toFixed(1)}
</div>
<div className="text-sm text-gray-500 mt-1">{category.name}</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scores" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Crown className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{judgeScores.map((score) => {
const judge = judges.find((j) => j.id === score.judgeId)
if (!judge) return null
const totalScore = Object.values(score.scores).reduce((sum, s) => sum + s, 0) / 5
return (
<div key={score.judgeId} className="border rounded-lg p-6">
<div className="flex items-center space-x-4 mb-4">
<Avatar className="w-12 h-12">
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
<AvatarFallback className="bg-purple-100 text-purple-700 text-lg">
{judge.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-lg">{judge.name}</h4>
<p className="text-gray-600">{judge.title}</p>
<div className="flex flex-wrap gap-1 mt-1">
{judge.expertise.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-purple-600">{totalScore.toFixed(1)}</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
<div className="grid grid-cols-5 gap-3 mb-4">
{[
{ key: "innovation", name: "創新性", icon: "💡" },
{ key: "technical", name: "技術性", icon: "⚙️" },
{ key: "usability", name: "實用性", icon: "🎯" },
{ key: "presentation", name: "展示效果", icon: "🎨" },
{ key: "impact", name: "影響力", icon: "🚀" },
].map((category) => (
<div key={category.key} className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg">{category.icon}</div>
<div className="text-lg font-bold text-gray-900">
{score.scores[category.key as keyof typeof score.scores]}
</div>
<div className="text-xs text-gray-500">{category.name}</div>
</div>
))}
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<h5 className="font-medium text-blue-900 mb-2"></h5>
<p className="text-blue-800">{score.comments}</p>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="experience" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Play className="w-5 h-5 text-green-500" />
<span></span>
</CardTitle>
<CardDescription> AI </CardDescription>
</CardHeader>
<CardContent className="text-center py-12">
<div className="w-24 h-24 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center mx-auto mb-6">
<IconComponent className="w-12 h-12 text-white" />
</div>
<h3 className="text-2xl font-bold mb-4">{app.name}</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{app.description}</p>
<Button
size="lg"
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
onClick={() => handleTryApp(app.id)}
>
<Play className="w-5 h-5 mr-2" />
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}
const renderTeamDetail = () => {
const team = getTeamById(ranking.teamId!)
if (!team) return null
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="members"></TabsTrigger>
<TabsTrigger value="apps"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-green-500 to-blue-500 rounded-xl flex items-center justify-center">
<Users className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{team.name}</CardTitle>
<CardDescription className="text-lg">
{team.members.find((m) => m.id === team.leader)?.name}
</CardDescription>
<div className="flex items-center space-x-4 mt-2">
<Badge variant="outline" className="bg-green-100 text-green-800 border-green-200">
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{team.department}
</Badge>
<div className="flex items-center space-x-1 text-2xl font-bold text-green-600">
<Trophy className="w-6 h-6" />
<span> {ranking.rank} </span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-green-600">
<Star className="w-6 h-6" />
<span>{ranking.totalScore.toFixed(1)}</span>
</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{team.apps.length}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{team.totalLikes}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{team.members.length}</div>
<div className="text-sm text-gray-500 mt-1"></div>
</div>
</div>
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<Building className="w-4 h-4 text-gray-500" />
<span className="text-sm">{team.department}</span>
</div>
<div className="flex items-center space-x-1">
<Mail className="w-4 h-4 text-gray-500" />
<span className="text-sm">{team.contactEmail}</span>
</div>
</div>
<div className="text-sm text-green-600 font-medium">
: {Math.round((team.totalLikes / team.apps.length) * 10) / 10}
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="members" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<UserCheck className="w-5 h-5 text-green-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{team.members.map((member) => (
<div
key={member.id}
className={`border rounded-lg p-4 ${
member.id === team.leader ? "border-green-300 bg-green-50" : "border-gray-200"
}`}
>
<div className="flex items-center space-x-3">
<Avatar>
<AvatarImage src={`/placeholder-40x40.png?height=40&width=40&text=${member.name[0]}`} />
<AvatarFallback className="bg-green-100 text-green-700">{member.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-semibold">{member.name}</h4>
{member.id === team.leader && (
<Badge variant="secondary" className="bg-green-100 text-green-800">
</Badge>
)}
</div>
<p className="text-sm text-gray-600">{member.role}</p>
<Badge variant="outline" className="text-xs mt-1">
{member.department}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="apps" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Trophy className="w-5 h-5 text-blue-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{team.apps.map((appId) => {
const app = aiApps.find((a) => a.id === appId)
if (!app) return null
const IconComponent = app.icon
const likes = getAppLikes(appId)
const views = getViewCount(appId)
const rating = getAppRating(appId)
return (
<Card key={appId} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gradient-to-r from-blue-100 to-purple-100 rounded-lg flex items-center justify-center">
<IconComponent className="w-6 h-6 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg">{app.name}</CardTitle>
<CardDescription>by {app.creator}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 mb-4">{app.description}</p>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Heart className="w-3 h-3" />
<span>{likes}</span>
</div>
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{views}</span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-3 h-3 text-yellow-500" />
<span>{rating.toFixed(1)}</span>
</div>
</div>
</div>
<Button
className="w-full bg-transparent"
variant="outline"
onClick={() => handleTryApp(app.id)}
>
<Play className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
)
})}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}
const renderProposalDetail = () => {
const proposal = getProposalById(ranking.proposalId!)
const team = ranking.team
const judgeScores = getProposalJudgeScores(ranking.proposalId!)
if (!proposal) return null
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="scores"></TabsTrigger>
<TabsTrigger value="team"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Lightbulb className="w-8 h-8 text-white" />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{proposal.title}</CardTitle>
<CardDescription className="text-lg">{team?.name}</CardDescription>
<div className="flex items-center space-x-4 mt-2">
<Badge variant="outline" className="bg-purple-100 text-purple-800 border-purple-200">
</Badge>
<div className="flex items-center space-x-1 text-2xl font-bold text-purple-600">
<Trophy className="w-6 h-6" />
<span> {ranking.rank} </span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-center mb-6">
<div className="text-3xl font-bold text-purple-600 mb-2">{ranking.totalScore.toFixed(1)}</div>
<div className="text-gray-500"></div>
</div>
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h5 className="font-semibold text-red-900 mb-2 flex items-center">
<AlertCircle className="w-5 h-5 mr-2" />
</h5>
<p className="text-red-800">{proposal.problemStatement}</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h5 className="font-semibold text-green-900 mb-2 flex items-center">
<CheckCircle className="w-5 h-5 mr-2" />
</h5>
<p className="text-green-800">{proposal.solution}</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h5 className="font-semibold text-blue-900 mb-2 flex items-center">
<Target className="w-5 h-5 mr-2" />
</h5>
<p className="text-blue-800">{proposal.expectedImpact}</p>
</div>
</div>
<Separator className="my-6" />
<div className="grid grid-cols-5 gap-4">
{[
{ key: "problemIdentification", name: "問題識別", icon: "🔍", color: "text-red-600" },
{ key: "solutionFeasibility", name: "方案可行性", icon: "⚙️", color: "text-blue-600" },
{ key: "innovation", name: "創新性", icon: "💡", color: "text-yellow-600" },
{ key: "impact", name: "預期影響", icon: "🚀", color: "text-green-600" },
{ key: "presentation", name: "展示效果", icon: "🎨", color: "text-purple-600" },
].map((category) => (
<div key={category.key} className="text-center p-4 bg-white border rounded-lg">
<div className="text-2xl mb-2">{category.icon}</div>
<div className={`text-xl font-bold ${category.color}`}>
{ranking.scores[category.key as keyof typeof ranking.scores].toFixed(1)}
</div>
<div className="text-sm text-gray-500 mt-1">{category.name}</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scores" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Crown className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{judgeScores.map((score) => {
const judge = judges.find((j) => j.id === score.judgeId)
if (!judge) return null
const totalScore = Object.values(score.scores).reduce((sum, s) => sum + s, 0) / 5
return (
<div key={score.judgeId} className="border rounded-lg p-6">
<div className="flex items-center space-x-4 mb-4">
<Avatar className="w-12 h-12">
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
<AvatarFallback className="bg-purple-100 text-purple-700 text-lg">
{judge.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-lg">{judge.name}</h4>
<p className="text-gray-600">{judge.title}</p>
<div className="flex flex-wrap gap-1 mt-1">
{judge.expertise.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-purple-600">{totalScore.toFixed(1)}</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
<div className="grid grid-cols-5 gap-3 mb-4">
{[
{ key: "problemIdentification", name: "問題識別", icon: "🔍" },
{ key: "solutionFeasibility", name: "方案可行性", icon: "⚙️" },
{ key: "innovation", name: "創新性", icon: "💡" },
{ key: "impact", name: "預期影響", icon: "🚀" },
{ key: "presentation", name: "展示效果", icon: "🎨" },
].map((category) => (
<div key={category.key} className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg">{category.icon}</div>
<div className="text-lg font-bold text-gray-900">
{score.scores[category.key as keyof typeof score.scores]}
</div>
<div className="text-xs text-gray-500">{category.name}</div>
</div>
))}
</div>
<div className="bg-purple-50 p-4 rounded-lg">
<h5 className="font-medium text-purple-900 mb-2"></h5>
<p className="text-purple-800">{score.comments}</p>
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="team" className="space-y-6">
{team && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="mb-6">
<h4 className="font-semibold text-lg mb-2">{team.name}</h4>
<div className="flex items-center space-x-4 text-sm text-gray-600">
<div className="flex items-center space-x-1">
<Building className="w-4 h-4" />
<span>{team.department}</span>
</div>
<div className="flex items-center space-x-1">
<Mail className="w-4 h-4" />
<span>{team.contactEmail}</span>
</div>
<div className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span> {new Date(proposal.submittedAt).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{team.members.map((member) => (
<div
key={member.id}
className={`border rounded-lg p-4 ${
member.id === team.leader ? "border-green-300 bg-green-50" : "border-gray-200"
}`}
>
<div className="flex items-center space-x-3">
<Avatar>
<AvatarImage src={`/placeholder-40x40.png?height=40&width=40&text=${member.name[0]}`} />
<AvatarFallback className="bg-green-100 text-green-700">{member.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-semibold">{member.name}</h4>
{member.id === team.leader && (
<Badge variant="secondary" className="bg-green-100 text-green-800">
</Badge>
)}
</div>
<p className="text-sm text-gray-600">{member.role}</p>
<Badge variant="outline" className="text-xs mt-1">
{member.department}
</Badge>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">
{competitionType === "individual" && "個人賽詳情"}
{competitionType === "team" && "團隊賽詳情"}
{competitionType === "proposal" && "提案賽詳情"}
</DialogTitle>
<DialogDescription>
{currentCompetition?.name} - {ranking.rank}
</DialogDescription>
</DialogHeader>
<div className="mt-6">
{competitionType === "individual" && renderIndividualDetail()}
{competitionType === "team" && renderTeamDetail()}
{competitionType === "proposal" && renderProposalDetail()}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,261 @@
"use client"
import { useState } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Slider } from "@/components/ui/slider"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Star, User, Award, MessageSquare, CheckCircle } from "lucide-react"
interface JudgeScoringDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
appId: string
appName: string
judgeId: string
}
export function JudgeScoringDialog({ open, onOpenChange, appId, appName, judgeId }: JudgeScoringDialogProps) {
const { judges, submitJudgeScore, getAppJudgeScores } = useCompetition()
const judge = judges.find((j) => j.id === judgeId)
const existingScore = getAppJudgeScores(appId).find((s) => s.judgeId === judgeId)
const [scores, setScores] = useState({
innovation: existingScore?.scores.innovation || 8,
technical: existingScore?.scores.technical || 8,
usability: existingScore?.scores.usability || 8,
presentation: existingScore?.scores.presentation || 8,
impact: existingScore?.scores.impact || 8,
})
const [comments, setComments] = useState(existingScore?.comments || "")
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
if (!judge) return null
const handleScoreChange = (category: keyof typeof scores, value: number[]) => {
setScores((prev) => ({
...prev,
[category]: value[0],
}))
}
const handleSubmit = async () => {
if (!comments.trim()) {
return
}
setIsSubmitting(true)
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000))
submitJudgeScore({
judgeId,
appId,
scores,
comments: comments.trim(),
})
setIsSubmitting(false)
setIsSubmitted(true)
// Auto close after success
setTimeout(() => {
setIsSubmitted(false)
onOpenChange(false)
}, 2000)
}
const totalScore = Object.values(scores).reduce((sum, score) => sum + score, 0)
const averageScore = (totalScore / 5).toFixed(1)
const scoreCategories = [
{
key: "innovation" as const,
name: "創新性",
description: "技術創新程度和獨特性",
icon: "💡",
},
{
key: "technical" as const,
name: "技術性",
description: "技術實現難度和完成度",
icon: "⚙️",
},
{
key: "usability" as const,
name: "實用性",
description: "實際應用價值和用戶體驗",
icon: "🎯",
},
{
key: "presentation" as const,
name: "展示效果",
description: "介面設計和展示完整性",
icon: "🎨",
},
{
key: "impact" as const,
name: "影響力",
description: "對業務和用戶的潛在影響",
icon: "🚀",
},
]
if (isSubmitted) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<div className="flex flex-col items-center space-y-4 py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600">{appName}</p>
</div>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Award className="w-5 h-5 text-purple-600" />
<span></span>
</DialogTitle>
<DialogDescription>{appName}</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Judge Info */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center space-x-3">
<Avatar>
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
</Avatar>
<div>
<h4 className="font-semibold">{judge.name}</h4>
<p className="text-sm text-gray-600">{judge.title}</p>
<div className="flex flex-wrap gap-1 mt-1">
{judge.expertise.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
</div>
</div>
</CardHeader>
</Card>
{/* Scoring Categories */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span></span>
<div className="text-right">
<div className="text-2xl font-bold text-purple-600">{averageScore}</div>
<div className="text-sm text-gray-500"></div>
</div>
</CardTitle>
<CardDescription>1-10</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{scoreCategories.map((category) => (
<div key={category.key} className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-lg">{category.icon}</span>
<div>
<h4 className="font-medium">{category.name}</h4>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-purple-600">{scores[category.key]}</div>
<div className="flex">
{[...Array(scores[category.key])].map((_, i) => (
<Star key={i} className="w-3 h-3 text-yellow-400 fill-current" />
))}
</div>
</div>
</div>
<Slider
value={[scores[category.key]]}
onValueChange={(value) => handleScoreChange(category.key, value)}
max={10}
min={1}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>1 ()</span>
<span>5 ()</span>
<span>10 ()</span>
</div>
</div>
))}
</CardContent>
</Card>
{/* Comments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-4 h-4" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Textarea
placeholder="請分享您對此應用的專業評價、優點、改進建議等..."
value={comments}
onChange={(e) => setComments(e.target.value)}
rows={4}
maxLength={1000}
/>
<div className="text-xs text-gray-500 mt-2">{comments.length}/1000 </div>
</CardContent>
</Card>
{/* Submit */}
<div className="flex space-x-3">
<Button
onClick={handleSubmit}
disabled={isSubmitting || !comments.trim()}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700"
>
{isSubmitting ? "提交中..." : existingScore ? "更新評分" : "提交評分"}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
</div>
{existingScore && (
<Alert>
<User className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,779 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useCompetition } from "@/contexts/competition-context"
import {
Search,
Heart,
Eye,
Trophy,
Calendar,
Users,
Target,
Lightbulb,
MessageSquare,
ImageIcon,
Mic,
TrendingUp,
Brain,
Zap,
Crown,
ChevronLeft,
ChevronRight,
ThumbsUp,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { LikeButton } from "@/components/like-button"
import { AppDetailDialog } from "@/components/app-detail-dialog"
import { TeamDetailDialog } from "@/components/competition/team-detail-dialog"
// AI applications data - empty for production
const aiApps: any[] = []
// Teams data - empty for production
const mockTeams: any[] = []
export function PopularityRankings() {
const { user, getLikeCount, getViewCount } = useAuth()
const { competitions, currentCompetition, setCurrentCompetition, judges } = useCompetition()
const [searchTerm, setSearchTerm] = useState("")
const [selectedDepartment, setSelectedDepartment] = useState("all")
const [selectedType, setSelectedType] = useState("all")
const [selectedCompetitionType, setSelectedCompetitionType] = useState("all")
const [selectedApp, setSelectedApp] = useState<any>(null)
const [showAppDetail, setShowAppDetail] = useState(false)
const [selectedTeam, setSelectedTeam] = useState<any>(null)
const [showTeamDetail, setShowTeamDetail] = useState(false)
const [individualCurrentPage, setIndividualCurrentPage] = useState(0)
const [teamCurrentPage, setTeamCurrentPage] = useState(0)
const ITEMS_PER_PAGE = 3
// Filter apps based on search criteria
const filteredApps = aiApps.filter((app) => {
const matchesSearch =
app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.creator.toLowerCase().includes(searchTerm.toLowerCase())
const matchesDepartment = selectedDepartment === "all" || app.department === selectedDepartment
const matchesType = selectedType === "all" || app.type === selectedType
const matchesCompetitionType = selectedCompetitionType === "all" || app.competitionType === selectedCompetitionType
return matchesSearch && matchesDepartment && matchesType && matchesCompetitionType
})
// Sort apps by like count (popularity) and group by competition type
const sortedApps = filteredApps.sort((a, b) => {
const likesA = getLikeCount(a.id.toString())
const likesB = getLikeCount(b.id.toString())
return likesB - likesA
})
// Group apps by competition type
const individualApps = sortedApps.filter((app) => app.competitionType === "individual")
const teamApps = sortedApps.filter((app) => app.competitionType === "team")
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
const getCompetitionTypeIcon = (type: string) => {
switch (type) {
case "individual":
return <Target className="w-4 h-4" />
case "team":
return <Users className="w-4 h-4" />
case "proposal":
return <Lightbulb className="w-4 h-4" />
case "mixed":
return <Trophy className="w-4 h-4" />
default:
return <Trophy className="w-4 h-4" />
}
}
const getCompetitionTypeText = (type: string) => {
switch (type) {
case "individual":
return "個人賽"
case "team":
return "團隊賽"
case "proposal":
return "提案賽"
case "mixed":
return "混合賽"
default:
return "競賽"
}
}
const getCompetitionTypeColor = (type: string) => {
switch (type) {
case "individual":
return "bg-blue-100 text-blue-800 border-blue-200"
case "team":
return "bg-green-100 text-green-800 border-green-200"
case "proposal":
return "bg-purple-100 text-purple-800 border-purple-200"
case "mixed":
return "bg-gradient-to-r from-blue-100 via-green-100 to-purple-100 text-gray-800 border-gray-200"
default:
return "bg-gray-100 text-gray-800 border-gray-200"
}
}
const handleOpenAppDetail = (app: any) => {
setSelectedApp(app)
setShowAppDetail(true)
}
const handleOpenTeamDetail = (team: any) => {
setSelectedTeam(team)
setShowTeamDetail(true)
}
const renderCompetitionSection = (apps: any[], title: string, competitionType: string) => {
if (apps.length === 0) {
return null
}
const currentPage = competitionType === "individual" ? individualCurrentPage : teamCurrentPage
const setCurrentPage = competitionType === "individual" ? setIndividualCurrentPage : setTeamCurrentPage
const totalPages = Math.ceil(apps.length / ITEMS_PER_PAGE)
const startIndex = currentPage * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
const currentApps = apps.slice(startIndex, endIndex)
return (
<Card className="mb-8">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
{competitionType === "individual" ? (
<Target className="w-5 h-5 text-blue-500" />
) : (
<Users className="w-5 h-5 text-green-500" />
)}
<span>{title}</span>
</CardTitle>
<CardDescription>
{currentCompetition?.name || "暫無進行中的競賽"} - {title} ( {apps.length} )
</CardDescription>
</div>
<div className="flex items-center space-x-3">
<Badge variant="outline" className={getCompetitionTypeColor(competitionType)}>
{getCompetitionTypeIcon(competitionType)}
<span className="ml-1">{getCompetitionTypeText(competitionType)}</span>
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Page indicator */}
{totalPages > 1 && (
<div className="text-center">
<div className="text-sm text-gray-600">
{startIndex + 1}-{Math.min(endIndex, apps.length)} {apps.length}
</div>
<div className="flex justify-center items-center space-x-2 mt-2">
{Array.from({ length: totalPages }).map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full transition-all duration-200 ${
index === currentPage ? "bg-blue-500 w-6" : "bg-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Carousel Container */}
<div className="relative">
{/* Left Arrow */}
{totalPages > 1 && currentPage > 0 && (
<button
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 bg-white rounded-full shadow-lg border border-gray-200 flex items-center justify-center hover:bg-gray-50 hover:shadow-xl transition-all duration-200 group"
>
<ChevronLeft className="w-5 h-5 text-gray-600 group-hover:text-gray-800" />
</button>
)}
{/* Right Arrow */}
{totalPages > 1 && currentPage < totalPages - 1 && (
<button
onClick={() => setCurrentPage(Math.min(totalPages - 1, currentPage + 1))}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 bg-white rounded-full shadow-lg border border-gray-200 flex items-center justify-center hover:bg-gray-50 hover:shadow-xl transition-all duration-200 group"
>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-gray-800" />
</button>
)}
{/* Apps Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 px-8">
{currentApps.map((app, index) => {
const likes = getLikeCount(app.id.toString())
const views = getViewCount(app.id.toString())
const globalRank = startIndex + index + 1
return (
<Card
key={app.id}
className="hover:shadow-lg transition-all duration-300 bg-gradient-to-br from-yellow-50 to-orange-50 border border-yellow-200 flex flex-col"
>
<CardContent className="p-4 flex flex-col flex-1">
<div className="flex items-start space-x-3 mb-4">
{/* Numbered Badge */}
<div className="w-12 h-12 bg-gradient-to-r from-yellow-400 to-orange-400 rounded-full flex items-center justify-center text-white font-bold text-xl shadow-md flex-shrink-0">
{globalRank}
</div>
{/* App Icon */}
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-gray-200 shadow-sm flex-shrink-0">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-gray-900 mb-1 truncate">{app.name}</h4>
<p className="text-sm text-gray-600 mb-2">by {app.creator}</p>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className={`${getTypeColor(app.type)} text-xs`}>
{app.type}
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200 text-xs">
{app.department}
</Badge>
</div>
</div>
</div>
{/* Description - flexible height */}
<div className="mb-4 flex-1">
<p className="text-sm text-gray-600 line-clamp-3">{app.description}</p>
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Eye className="w-4 h-4" />
<span>{views} </span>
</div>
{globalRank <= 3 && (
<div className="flex items-center space-x-1 text-orange-600 font-medium">
<Trophy className="w-4 h-4" />
<span></span>
</div>
)}
</div>
</div>
{/* Action Buttons - Always at bottom with consistent positioning */}
<div className="flex items-center justify-between pt-3 border-t border-yellow-200 mt-auto">
{/* Enhanced Like Button */}
<div className="flex items-center">
<LikeButton appId={app.id.toString()} size="lg" />
</div>
{/* View Details Button */}
<Button
variant="outline"
size="sm"
className="bg-white hover:bg-gray-50 border-gray-300 shadow-sm"
onClick={() => handleOpenAppDetail(app)}
>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</div>
</CardContent>
</Card>
)
}
const renderTeamCompetitionSection = (teams: any[], title: string) => {
if (teams.length === 0) {
return null
}
// Calculate team popularity score: total apps × highest like count
const teamsWithScores = teams
.map((team) => {
const appLikes = team.apps.map((appId: string) => getLikeCount(appId))
const maxLikes = Math.max(...appLikes, 0)
const totalApps = team.apps.length
const popularityScore = totalApps * maxLikes
return {
...team,
popularityScore,
maxLikes,
totalApps,
totalViews: team.apps.reduce((sum: number, appId: string) => sum + getViewCount(appId), 0),
}
})
.sort((a, b) => b.popularityScore - a.popularityScore)
const currentPage = teamCurrentPage
const setCurrentPage = setTeamCurrentPage
const totalPages = Math.ceil(teamsWithScores.length / ITEMS_PER_PAGE)
const startIndex = currentPage * ITEMS_PER_PAGE
const endIndex = startIndex + ITEMS_PER_PAGE
const currentTeams = teamsWithScores.slice(startIndex, endIndex)
return (
<Card className="mb-8">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span>{title}</span>
</CardTitle>
<CardDescription>
{currentCompetition?.name || "暫無進行中的競賽"} - {title} ( {teamsWithScores.length} )
</CardDescription>
</div>
<div className="flex items-center space-x-3">
<Badge variant="outline" className="bg-green-100 text-green-800 border-green-200">
<Users className="w-4 h-4" />
<span className="ml-1"></span>
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Page indicator */}
{totalPages > 1 && (
<div className="text-center">
<div className="text-sm text-gray-600">
{startIndex + 1}-{Math.min(endIndex, teamsWithScores.length)} {teamsWithScores.length}{" "}
</div>
<div className="flex justify-center items-center space-x-2 mt-2">
{Array.from({ length: totalPages }).map((_, index) => (
<div
key={index}
className={`w-2 h-2 rounded-full transition-all duration-200 ${
index === currentPage ? "bg-green-500 w-6" : "bg-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Carousel Container */}
<div className="relative">
{/* Left Arrow */}
{totalPages > 1 && currentPage > 0 && (
<button
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-6 z-10 w-12 h-12 bg-white rounded-full shadow-lg border border-gray-200 flex items-center justify-center hover:bg-gray-50 hover:shadow-xl transition-all duration-200 group"
>
<ChevronLeft className="w-5 h-5 text-gray-600 group-hover:text-gray-800" />
</button>
)}
{/* Right Arrow */}
{totalPages > 1 && currentPage < totalPages - 1 && (
<button
onClick={() => setCurrentPage(Math.min(totalPages - 1, currentPage + 1))}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-6 z-10 w-12 h-12 bg-white rounded-full shadow-lg border border-gray-200 flex items-center justify-center hover:bg-gray-50 hover:shadow-xl transition-all duration-200 group"
>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-gray-800" />
</button>
)}
{/* Teams Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 px-8">
{currentTeams.map((team, index) => {
const globalRank = startIndex + index + 1
const leader = team.members.find((m: any) => m.id === team.leader)
return (
<Card
key={team.id}
className="hover:shadow-lg transition-all duration-300 bg-gradient-to-br from-green-50 to-blue-50 border border-green-200 flex flex-col"
>
<CardContent className="p-4 flex flex-col flex-1">
<div className="flex items-start space-x-3 mb-4">
{/* Numbered Badge */}
<div className="w-12 h-12 bg-gradient-to-r from-green-400 to-blue-400 rounded-full flex items-center justify-center text-white font-bold text-xl shadow-md flex-shrink-0">
{globalRank}
</div>
{/* Team Icon */}
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center border border-gray-200 shadow-sm flex-shrink-0">
<Users className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-gray-900 mb-1 truncate">{team.name}</h4>
<p className="text-sm text-gray-600 mb-2">{leader?.name}</p>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="bg-green-100 text-green-800 border-green-200 text-xs">
</Badge>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200 text-xs">
{team.department}
</Badge>
</div>
</div>
</div>
{/* Team Members */}
<div className="mb-4 flex-1">
<h5 className="text-sm font-medium text-gray-700 mb-2"> ({team.members.length})</h5>
<div className="space-y-1">
{team.members.slice(0, 3).map((member: any) => (
<div key={member.id} className="flex items-center space-x-2 text-xs">
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center text-green-700 font-medium">
{member.name[0]}
</div>
<span className="text-gray-600">{member.name}</span>
<span className="text-gray-400">({member.role})</span>
</div>
))}
{team.members.length > 3 && (
<div className="text-xs text-gray-500 ml-8"> {team.members.length - 3} ...</div>
)}
</div>
</div>
{/* Apps Info */}
<div className="mb-4">
<h5 className="text-sm font-medium text-gray-700 mb-2"></h5>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="bg-white p-2 rounded border">
<div className="font-bold text-blue-600">{team.totalApps}</div>
<div className="text-gray-500"></div>
</div>
<div className="bg-white p-2 rounded border">
<div className="font-bold text-red-600">{team.maxLikes}</div>
<div className="text-gray-500"></div>
</div>
</div>
</div>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4 text-sm text-gray-500">
<div className="flex items-center space-x-1">
<Eye className="w-4 h-4" />
<span>{team.totalViews} </span>
</div>
{globalRank <= 3 && (
<div className="flex items-center space-x-1 text-green-600 font-medium">
<Trophy className="w-4 h-4" />
<span></span>
</div>
)}
</div>
</div>
{/* Popularity Score */}
<div className="mb-4 p-3 bg-gradient-to-r from-green-100 to-blue-100 rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-green-700">{team.popularityScore}</div>
<div className="text-xs text-green-600"></div>
<div className="text-xs text-gray-500 mt-1">
{team.totalApps} × {team.maxLikes}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-3 border-t border-green-200 mt-auto">
<div className="flex items-center space-x-2">
<ThumbsUp className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium">
{team.apps.reduce((sum: number, appId: string) => sum + getLikeCount(appId), 0)}
</span>
</div>
<Button
variant="outline"
size="sm"
className="bg-white hover:bg-gray-50 border-gray-300 shadow-sm"
onClick={() => handleOpenTeamDetail(team)}
>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-8">
{/* Competition Info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center space-x-2">
<Trophy className="w-5 h-5 text-yellow-500" />
<span></span>
</CardTitle>
<CardDescription>
{currentCompetition?.name || "暫無進行中的競賽"} -
</CardDescription>
</div>
<div className="flex items-center space-x-3">
<Select
value={currentCompetition?.id || ""}
onValueChange={(value) => {
const competition = competitions.find((c) => c.id === value)
setCurrentCompetition(competition || null)
}}
>
<SelectTrigger className="w-64">
<SelectValue placeholder="選擇競賽" />
</SelectTrigger>
<SelectContent>
{competitions.map((competition) => (
<SelectItem key={competition.id} value={competition.id}>
<div className="flex items-center space-x-2">
{getCompetitionTypeIcon(competition.type)}
<span>{competition.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
{currentCompetition && (
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-sm">
{currentCompetition.year}{currentCompetition.month}
</span>
</div>
<div className="flex items-center space-x-2">
<Badge variant="outline" className={getCompetitionTypeColor(currentCompetition.type)}>
{getCompetitionTypeIcon(currentCompetition.type)}
<span className="ml-1">{getCompetitionTypeText(currentCompetition.type)}</span>
</Badge>
</div>
<div className="flex items-center space-x-2">
<Users className="w-4 h-4 text-gray-500" />
<span className="text-sm">{filteredApps.length} </span>
</div>
<div className="flex items-center space-x-2">
<Badge
variant={currentCompetition.status === "completed" ? "secondary" : "default"}
className={
currentCompetition.status === "completed"
? "bg-green-100 text-green-800"
: currentCompetition.status === "judging"
? "bg-orange-100 text-orange-800"
: currentCompetition.status === "active"
? "bg-blue-100 text-blue-800"
: "bg-purple-100 text-purple-800"
}
>
{currentCompetition.status === "completed"
? "已完成"
: currentCompetition.status === "judging"
? "評審中"
: currentCompetition.status === "active"
? "進行中"
: "即將開始"}
</Badge>
</div>
</div>
<p className="text-gray-600 mt-4">{currentCompetition.description}</p>
</CardContent>
)}
</Card>
{/* Judge Panel */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Crown className="w-5 h-5 text-purple-500" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{judges.map((judge) => (
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<Avatar>
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-medium">{judge.name}</h4>
<p className="text-sm text-gray-600">{judge.title}</p>
<div className="flex flex-wrap gap-1 mt-1">
{judge.expertise.slice(0, 2).map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Search and Filter Section */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col lg:flex-row gap-4 items-center">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜尋應用名稱、描述或創作者..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-3">
<Select value={selectedCompetitionType} onValueChange={setSelectedCompetitionType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="競賽類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="individual"></SelectItem>
<SelectItem value="team"></SelectItem>
</SelectContent>
</Select>
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
<SelectTrigger className="w-32">
<SelectValue placeholder="部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-32">
<SelectValue placeholder="應用類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Competition Rankings */}
{currentCompetition ? (
<div className="space-y-8">
{/* Individual Competition Section */}
{(selectedCompetitionType === "all" || selectedCompetitionType === "individual") &&
renderCompetitionSection(individualApps, "個人賽", "individual")}
{/* Team Competition Section */}
{(selectedCompetitionType === "all" || selectedCompetitionType === "team") &&
renderTeamCompetitionSection(
mockTeams.filter(
(team) =>
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
team.members.some((member: any) => member.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
selectedDepartment === "all" ||
team.department === selectedDepartment,
),
"團隊賽",
)}
{/* No Results */}
{filteredApps.length === 0 && (
<Card>
<CardContent className="text-center py-12">
<Heart className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500">調</p>
<Button
variant="outline"
className="mt-4 bg-transparent"
onClick={() => {
setSearchTerm("")
setSelectedDepartment("all")
setSelectedType("all")
setSelectedCompetitionType("all")
}}
>
</Button>
</CardContent>
</Card>
)}
</div>
) : (
<Card>
<CardContent className="text-center py-12">
<Trophy className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500"></p>
</CardContent>
</Card>
)}
{/* App Detail Dialog */}
{selectedApp && <AppDetailDialog open={showAppDetail} onOpenChange={setShowAppDetail} app={selectedApp} />}
{/* Team Detail Dialog */}
{selectedTeam && <TeamDetailDialog open={showTeamDetail} onOpenChange={setShowTeamDetail} team={selectedTeam} />}
</div>
)
}

View File

@@ -0,0 +1,477 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { useCompetition } from "@/contexts/competition-context"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { ChevronLeft, ChevronRight, Trophy, Users, FileText, CheckCircle, Target, Award, Loader2 } from "lucide-react"
interface RegistrationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface RegistrationData {
// 應用資訊
appName: string
appDescription: string
appType: string
techStack: string
mainFeatures: string
// 團隊資訊
teamName: string
teamSize: string
contactName: string
contactEmail: string
contactPhone: string
department: string
// 參賽動機
motivation: string
expectedOutcome: string
agreeTerms: boolean
}
export function RegistrationDialog({ open, onOpenChange }: RegistrationDialogProps) {
const { user } = useAuth()
const { currentCompetition } = useCompetition()
const [currentStep, setCurrentStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [applicationId, setApplicationId] = useState("")
const [formData, setFormData] = useState<RegistrationData>({
appName: "",
appDescription: "",
appType: "",
techStack: "",
mainFeatures: "",
teamName: "",
teamSize: "1",
contactName: user?.name || "",
contactEmail: user?.email || "",
contactPhone: "",
department: "",
motivation: "",
expectedOutcome: "",
agreeTerms: false,
})
const totalSteps = 3
const progress = (currentStep / totalSteps) * 100
const handleInputChange = (field: keyof RegistrationData, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const validateStep = (step: number): boolean => {
switch (step) {
case 1:
return !!(formData.appName && formData.appDescription && formData.appType && formData.techStack)
case 2:
return !!(formData.teamName && formData.contactName && formData.contactEmail && formData.department)
case 3:
return !!(formData.motivation && formData.agreeTerms)
default:
return false
}
}
const handleNext = () => {
if (validateStep(currentStep) && currentStep < totalSteps) {
setCurrentStep((prev) => prev + 1)
}
}
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep((prev) => prev - 1)
}
}
const handleSubmit = async () => {
if (!validateStep(3)) return
setIsSubmitting(true)
// 模擬提交過程
await new Promise((resolve) => setTimeout(resolve, 2000))
// 生成申請編號
const id = `REG${Date.now().toString().slice(-6)}`
setApplicationId(id)
setIsSubmitted(true)
setIsSubmitting(false)
}
const handleClose = () => {
if (isSubmitted) {
// 重置表單
setCurrentStep(1)
setIsSubmitted(false)
setApplicationId("")
setFormData({
appName: "",
appDescription: "",
appType: "",
techStack: "",
mainFeatures: "",
teamName: "",
teamSize: "1",
contactName: user?.name || "",
contactEmail: user?.email || "",
contactPhone: "",
department: "",
motivation: "",
expectedOutcome: "",
agreeTerms: false,
})
}
onOpenChange(false)
}
const renderStepContent = () => {
if (isSubmitted) {
return (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4"></p>
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-600 mb-1"></p>
<p className="text-lg font-mono font-semibold text-gray-900">{applicationId}</p>
</div>
<div className="text-sm text-gray-500 space-y-1">
<p> 3-5 </p>
<p> </p>
<p> </p>
</div>
</div>
)
}
switch (currentStep) {
case 1:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<FileText className="w-12 h-12 text-blue-600 mx-auto mb-3" />
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-gray-600"> AI </p>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="appName"> *</Label>
<Input
id="appName"
value={formData.appName}
onChange={(e) => handleInputChange("appName", e.target.value)}
placeholder="請輸入應用名稱"
/>
</div>
<div>
<Label htmlFor="appDescription"> *</Label>
<Textarea
id="appDescription"
value={formData.appDescription}
onChange={(e) => handleInputChange("appDescription", e.target.value)}
placeholder="請簡要描述您的應用功能和特色"
rows={3}
/>
</div>
<div>
<Label htmlFor="appType"> *</Label>
<Select value={formData.appType} onValueChange={(value) => handleInputChange("appType", value)}>
<SelectTrigger>
<SelectValue placeholder="選擇應用類型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="文字處理"></SelectItem>
<SelectItem value="圖像生成"></SelectItem>
<SelectItem value="語音辨識"></SelectItem>
<SelectItem value="推薦系統"></SelectItem>
<SelectItem value="數據分析"></SelectItem>
<SelectItem value="自動化工具"></SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="techStack"> *</Label>
<Input
id="techStack"
value={formData.techStack}
onChange={(e) => handleInputChange("techStack", e.target.value)}
placeholder="例如Python, TensorFlow, React, Node.js"
/>
</div>
<div>
<Label htmlFor="mainFeatures"></Label>
<Textarea
id="mainFeatures"
value={formData.mainFeatures}
onChange={(e) => handleInputChange("mainFeatures", e.target.value)}
placeholder="請列出應用的主要功能特色"
rows={2}
/>
</div>
</div>
</div>
)
case 2:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<Users className="w-12 h-12 text-green-600 mx-auto mb-3" />
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-gray-600"></p>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="teamName"> *</Label>
<Input
id="teamName"
value={formData.teamName}
onChange={(e) => handleInputChange("teamName", e.target.value)}
placeholder="請輸入團隊名稱"
/>
</div>
<div>
<Label htmlFor="teamSize"></Label>
<Select value={formData.teamSize} onValueChange={(value) => handleInputChange("teamSize", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 </SelectItem>
<SelectItem value="2">2 </SelectItem>
<SelectItem value="3">3 </SelectItem>
<SelectItem value="4">4 </SelectItem>
<SelectItem value="5+">5 </SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div>
<Label htmlFor="contactName"> *</Label>
<Input
id="contactName"
value={formData.contactName}
onChange={(e) => handleInputChange("contactName", e.target.value)}
placeholder="請輸入聯絡人姓名"
/>
</div>
<div>
<Label htmlFor="contactEmail"> *</Label>
<Input
id="contactEmail"
type="email"
value={formData.contactEmail}
onChange={(e) => handleInputChange("contactEmail", e.target.value)}
placeholder="請輸入聯絡人信箱"
/>
</div>
<div>
<Label htmlFor="contactPhone"></Label>
<Input
id="contactPhone"
value={formData.contactPhone}
onChange={(e) => handleInputChange("contactPhone", e.target.value)}
placeholder="請輸入聯絡電話"
/>
</div>
<div>
<Label htmlFor="department"> *</Label>
<Select value={formData.department} onValueChange={(value) => handleInputChange("department", value)}>
<SelectTrigger>
<SelectValue placeholder="選擇所屬部門" />
</SelectTrigger>
<SelectContent>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
<SelectItem value="其他"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)
case 3:
return (
<div className="space-y-6">
<div className="text-center mb-6">
<Target className="w-12 h-12 text-purple-600 mx-auto mb-3" />
<h3 className="text-lg font-semibold"></h3>
<p className="text-sm text-gray-600"></p>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="motivation"> *</Label>
<Textarea
id="motivation"
value={formData.motivation}
onChange={(e) => handleInputChange("motivation", e.target.value)}
placeholder="請分享您參加此次競賽的動機和期望"
rows={3}
/>
</div>
<div>
<Label htmlFor="expectedOutcome"></Label>
<Textarea
id="expectedOutcome"
value={formData.expectedOutcome}
onChange={(e) => handleInputChange("expectedOutcome", e.target.value)}
placeholder="您希望通過此次競賽達成什麼目標?"
rows={2}
/>
</div>
{/* 競賽資訊確認 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center space-x-2">
<Trophy className="w-4 h-4 text-yellow-600" />
<span></span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-medium">{currentCompetition?.name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-medium">
{currentCompetition?.year}{currentCompetition?.month}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600"></span>
<Badge variant="secondary" className="bg-green-100 text-green-800">
{currentCompetition?.status === "active" ? "進行中" : "即將開始"}
</Badge>
</div>
</CardContent>
</Card>
{/* 條款同意 */}
<div className="flex items-start space-x-2">
<Checkbox
id="agreeTerms"
checked={formData.agreeTerms}
onCheckedChange={(checked) => handleInputChange("agreeTerms", checked as boolean)}
/>
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed">
使
</Label>
</div>
</div>
</div>
)
default:
return null
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Trophy className="w-5 h-5 text-yellow-600" />
<span></span>
</DialogTitle>
<DialogDescription>{currentCompetition?.name}</DialogDescription>
</DialogHeader>
{!isSubmitted && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600">
{currentStep} / {totalSteps}
</span>
<span className="text-sm text-gray-600">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
<div className="min-h-[400px]">{renderStepContent()}</div>
{!isSubmitted && (
<div className="flex justify-between pt-6 border-t">
<Button variant="outline" onClick={handlePrevious} disabled={currentStep === 1}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
{currentStep < totalSteps ? (
<Button onClick={handleNext} disabled={!validateStep(currentStep)}>
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={!validateStep(3) || isSubmitting}
className="bg-gradient-to-r from-orange-600 to-yellow-600 hover:from-orange-700 hover:to-yellow-700"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Award className="w-4 h-4 mr-2" />
</>
)}
</Button>
)}
</div>
)}
{isSubmitted && (
<div className="flex justify-center pt-6 border-t">
<Button onClick={handleClose} className="w-full">
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,325 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Users,
Mail,
Eye,
Heart,
Trophy,
Star,
MessageSquare,
ImageIcon,
Mic,
TrendingUp,
Brain,
Zap,
ExternalLink,
} from "lucide-react"
import { useAuth } from "@/contexts/auth-context"
import { LikeButton } from "@/components/like-button"
import { AppDetailDialog } from "@/components/app-detail-dialog"
interface TeamDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
team: any
}
// App data for team apps - empty for production
const getAppDetails = (appId: string) => {
return {
id: appId,
name: "",
type: "",
description: "",
icon: null,
fullDescription: "",
features: [],
author: "",
category: "",
tags: [],
demoUrl: "",
sourceUrl: "",
}
}
const getTypeColor = (type: string) => {
const colors = {
: "bg-blue-100 text-blue-800 border-blue-200",
: "bg-purple-100 text-purple-800 border-purple-200",
: "bg-green-100 text-green-800 border-green-200",
: "bg-orange-100 text-orange-800 border-orange-200",
}
return colors[type as keyof typeof colors] || "bg-gray-100 text-gray-800 border-gray-200"
}
export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogProps) {
const { getLikeCount, getViewCount, getAppRating } = useAuth()
const [selectedTab, setSelectedTab] = useState("overview")
const [selectedApp, setSelectedApp] = useState<any>(null)
const [appDetailOpen, setAppDetailOpen] = useState(false)
if (!team) return null
const leader = team.members.find((m: any) => m.id === team.leader)
const handleAppClick = (appId: string) => {
const appDetails = getAppDetails(appId)
// Create app object that matches AppDetailDialog interface
const app = {
id: Number.parseInt(appId),
name: appDetails.name,
type: appDetails.type,
department: team.department, // Use team's department
description: appDetails.description,
icon: appDetails.icon,
creator: appDetails.author,
featured: false,
judgeScore: 0,
}
setSelectedApp(app)
setAppDetailOpen(true)
}
const totalLikes = team.apps.reduce((sum: number, appId: string) => sum + getLikeCount(appId), 0)
const totalViews = team.apps.reduce((sum: number, appId: string) => sum + getViewCount(appId), 0)
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span>{team.name}</span>
</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Team Overview */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="text-center p-4 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{team.members.length}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{team.apps.length}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="text-center p-4 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{totalLikes}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">{totalViews}</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
</CardContent>
</Card>
{/* Tab Navigation */}
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
<Button
variant={selectedTab === "overview" ? "default" : "ghost"}
size="sm"
onClick={() => setSelectedTab("overview")}
className="flex-1"
>
</Button>
<Button
variant={selectedTab === "members" ? "default" : "ghost"}
size="sm"
onClick={() => setSelectedTab("members")}
className="flex-1"
>
</Button>
<Button
variant={selectedTab === "apps" ? "default" : "ghost"}
size="sm"
onClick={() => setSelectedTab("apps")}
className="flex-1"
>
</Button>
</div>
{/* Tab Content */}
{selectedTab === "overview" && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-gray-700"></label>
<p className="text-gray-900">{team.name}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<Badge variant="outline" className="ml-2">
{team.department}
</Badge>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<p className="text-gray-900">{leader?.name}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-700"></label>
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-500" />
<p className="text-gray-900">{team.contactEmail}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 border rounded-lg">
<Trophy className="w-8 h-8 text-yellow-500 mx-auto mb-2" />
<div className="text-lg font-bold">{team.popularityScore}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="text-center p-4 border rounded-lg">
<Eye className="w-8 h-8 text-blue-500 mx-auto mb-2" />
<div className="text-lg font-bold">{totalViews}</div>
<div className="text-sm text-gray-600"></div>
</div>
<div className="text-center p-4 border rounded-lg">
<Heart className="w-8 h-8 text-red-500 mx-auto mb-2" />
<div className="text-lg font-bold">{totalLikes}</div>
<div className="text-sm text-gray-600"></div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{selectedTab === "members" && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> ({team.members.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{team.members.map((member: any, index: number) => (
<div key={member.id} className="flex items-center space-x-3 p-4 border rounded-lg">
<Avatar className="w-12 h-12">
<AvatarImage src={`/placeholder-40x40.png`} />
<AvatarFallback className="bg-green-100 text-green-700 font-medium">
{member.name[0]}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-medium">{member.name}</h4>
{member.id === team.leader && (
<Badge variant="default" className="bg-yellow-100 text-yellow-800 text-xs">
</Badge>
)}
</div>
<p className="text-sm text-gray-600">{member.role}</p>
<p className="text-xs text-gray-500">{member.department}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{selectedTab === "apps" && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> ({team.apps.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{team.apps.map((appId: string) => {
const app = getAppDetails(appId)
const IconComponent = app.icon
const likes = getLikeCount(appId)
const views = getViewCount(appId)
const rating = getAppRating(appId)
return (
<Card
key={appId}
className="hover:shadow-md transition-all duration-200 cursor-pointer group"
onClick={() => handleAppClick(appId)}
>
<CardContent className="p-4 flex flex-col h-full">
<div className="flex items-start space-x-3 mb-3">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<IconComponent className="w-5 h-5 text-gray-600 group-hover:text-blue-600" />
</div>
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-medium text-gray-900 group-hover:text-blue-600 transition-colors">
{app.name}
</h4>
<ExternalLink className="w-3 h-3 text-gray-400 group-hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-all" />
</div>
<Badge variant="outline" className={`${getTypeColor(app.type)} text-xs mt-1`}>
{app.type}
</Badge>
</div>
</div>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{app.description}</p>
<div className="flex items-center justify-between mt-auto">
<div className="flex items-center space-x-3 text-xs text-gray-500">
<div className="flex items-center space-x-1">
<Eye className="w-3 h-3" />
<span>{views}</span>
</div>
<div className="flex items-center space-x-1">
<Star className="w-3 h-3 text-yellow-500" />
<span>{rating.toFixed(1)}</span>
</div>
</div>
<div onClick={(e) => e.stopPropagation()}>
<LikeButton appId={appId} size="sm" />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
{/* App Detail Dialog */}
{selectedApp && <AppDetailDialog open={appDetailOpen} onOpenChange={setAppDetailOpen} app={selectedApp} />}
</>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Heart } from "lucide-react"
import { cn } from "@/lib/utils"
interface FavoriteButtonProps {
appId: string
initialFavorited?: boolean
onToggle?: (appId: string, isFavorited: boolean) => void
size?: "sm" | "md" | "lg"
variant?: "default" | "ghost" | "outline"
}
export function FavoriteButton({
appId,
initialFavorited = false,
onToggle,
size = "md",
variant = "ghost",
}: FavoriteButtonProps) {
const [isFavorited, setIsFavorited] = useState(initialFavorited)
const [isLoading, setIsLoading] = useState(false)
const handleToggle = async () => {
setIsLoading(true)
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 300))
const newFavoriteState = !isFavorited
setIsFavorited(newFavoriteState)
// Call the callback if provided
onToggle?.(appId, newFavoriteState)
} catch (error) {
console.error("Failed to toggle favorite:", error)
} finally {
setIsLoading(false)
}
}
const sizeClasses = {
sm: "h-8 w-8",
md: "h-9 w-9",
lg: "h-10 w-10",
}
const iconSizes = {
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
}
return (
<Button
variant={variant}
size="icon"
className={cn(sizeClasses[size], "transition-all duration-200", isFavorited && "text-red-500 hover:text-red-600")}
onClick={handleToggle}
disabled={isLoading}
title={isFavorited ? "取消收藏" : "加入收藏"}
>
<Heart
className={cn(
iconSizes[size],
"transition-all duration-200",
isFavorited && "fill-current",
isLoading && "animate-pulse",
)}
/>
</Button>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Heart, ExternalLink } from "lucide-react"
// Favorite apps data - empty for production
const mockFavoriteApps: any[] = []
export function FavoritesPage() {
const { user } = useAuth()
const [sortBy, setSortBy] = useState("name")
const [filterDepartment, setFilterDepartment] = useState("all")
const handleUseApp = (app: any) => {
// Open app in new tab
window.open(app.url, "_blank")
console.log(`Opening app: ${app.name}`)
}
const filteredAndSortedApps = mockFavoriteApps
.filter((app) => filterDepartment === "all" || app.department === filterDepartment)
.sort((a, b) => {
switch (sortBy) {
case "name":
return a.name.localeCompare(b.name)
case "creator":
return a.creator.localeCompare(b.creator)
case "department":
return a.department.localeCompare(b.department)
default:
return 0
}
})
return (
<div className="space-y-6">
{/* Filter and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex gap-3">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-32">
<SelectValue placeholder="排序方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name"></SelectItem>
<SelectItem value="creator"></SelectItem>
<SelectItem value="department"></SelectItem>
</SelectContent>
</Select>
<Select value={filterDepartment} onValueChange={setFilterDepartment}>
<SelectTrigger className="w-32">
<SelectValue placeholder="部門篩選" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="HQBU">HQBU</SelectItem>
<SelectItem value="ITBU">ITBU</SelectItem>
<SelectItem value="MBU1">MBU1</SelectItem>
<SelectItem value="SBU">SBU</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500"> {filteredAndSortedApps.length} </div>
</div>
{/* Favorites Grid */}
{filteredAndSortedApps.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAndSortedApps.map((app) => (
<Card key={app.id} className="h-full flex flex-col hover:shadow-lg transition-shadow">
<CardContent className="p-6 flex flex-col h-full">
{/* Header with heart icon */}
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 flex-1 pr-2">{app.name}</h3>
<Heart className="w-5 h-5 text-red-500 fill-current flex-shrink-0" />
</div>
{/* Description */}
<p className="text-gray-600 text-sm mb-4 line-clamp-2 flex-grow">{app.description}</p>
{/* Developer and Department */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-700">: {app.creator}</span>
<Badge variant="outline" className="bg-gray-100 text-gray-700 border-gray-200">
{app.department}
</Badge>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-2 mb-6 flex-grow">
{app.tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
{/* Action Button */}
<div className="mt-auto flex-shrink-0">
<Button className="w-full bg-black hover:bg-gray-800 text-white" onClick={() => handleUseApp(app)}>
<ExternalLink className="w-4 h-4 mr-2" />
使
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-12">
<Heart className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-600 mb-2"></h3>
<p className="text-gray-500">
{filterDepartment !== "all"
? "該部門暫無收藏的應用,請嘗試其他篩選條件"
: "您還沒有收藏任何應用,快去探索並收藏您喜歡的 AI 應用吧!"}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,98 @@
"use client"
import type React from "react"
import { useState } from "react"
import { ThumbsUp } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/contexts/auth-context"
import { useToast } from "@/hooks/use-toast"
import { cn } from "@/lib/utils"
interface LikeButtonProps {
appId: string
size?: "sm" | "default" | "lg"
className?: string
showCount?: boolean
}
export function LikeButton({ appId, size = "default", className, showCount = true }: LikeButtonProps) {
const { user, likeApp, getAppLikes, hasLikedToday } = useAuth()
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
const likeCount = getAppLikes(appId)
const hasLiked = user ? hasLikedToday(appId) : false
const handleLike = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!user) {
toast({
title: "請先登入",
description: "您需要登入才能為應用按讚",
variant: "destructive",
})
return
}
if (hasLiked) {
toast({
title: "今日已按讚",
description: "您今天已經為這個應用按過讚了",
variant: "destructive",
})
return
}
setIsLoading(true)
try {
await likeApp(appId)
toast({
title: "按讚成功!",
description: "感謝您的支持",
})
} catch (error) {
toast({
title: "按讚失敗",
description: "請稍後再試",
variant: "destructive",
})
} finally {
setIsLoading(false)
}
}
const sizeClasses = {
sm: "h-6 px-2 text-xs gap-1",
default: "h-8 px-3 text-sm gap-1.5",
lg: "h-10 px-4 text-base gap-2",
}
const iconSizes = {
sm: "w-3 h-3",
default: "w-4 h-4",
lg: "w-5 h-5",
}
return (
<Button
variant="ghost"
size="sm"
onClick={handleLike}
disabled={isLoading}
className={cn(
sizeClasses[size],
"flex items-center",
hasLiked
? "text-blue-600 hover:text-blue-700 hover:bg-blue-50"
: "text-gray-500 hover:text-blue-600 hover:bg-blue-50",
"transition-all duration-200",
className,
)}
>
<ThumbsUp className={cn(iconSizes[size], hasLiked ? "fill-current" : "")} />
{showCount && <span>{likeCount}</span>}
</Button>
)
}

View File

@@ -0,0 +1,470 @@
"use client"
import { useState } from "react"
import { useAuth } from "@/contexts/auth-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Separator } from "@/components/ui/separator"
import { Star, MessageSquare, ThumbsUp, ThumbsDown, Edit, Trash2, MoreHorizontal } from "lucide-react"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
interface Review {
id: string
userId: string
userName: string
userAvatar?: string
userDepartment: string
rating: number
comment: string
createdAt: string
updatedAt?: string
helpful: number
notHelpful: number
userHelpfulVotes: string[] // user IDs who voted helpful
userNotHelpfulVotes: string[] // user IDs who voted not helpful
}
interface ReviewSystemProps {
appId: string
appName: string
currentRating: number
onRatingUpdate: (newRating: number, reviewCount: number) => void
}
export function ReviewSystem({ appId, appName, currentRating, onRatingUpdate }: ReviewSystemProps) {
const { user, updateAppRating } = useAuth()
// Load reviews from localStorage
const [reviews, setReviews] = useState<Review[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(`reviews_${appId}`)
return saved ? JSON.parse(saved) : []
}
return []
})
const [showReviewForm, setShowReviewForm] = useState(false)
const [newRating, setNewRating] = useState(5)
const [newComment, setNewComment] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [editingReview, setEditingReview] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<"newest" | "oldest" | "helpful">("newest")
const userReview = reviews.find((review) => review.userId === user?.id)
const canReview = user && !userReview
// Save reviews to localStorage and update app rating
const saveReviews = (updatedReviews: Review[]) => {
if (typeof window !== "undefined") {
localStorage.setItem(`reviews_${appId}`, JSON.stringify(updatedReviews))
}
setReviews(updatedReviews)
// Calculate new average rating and update in context
if (updatedReviews.length > 0) {
const avgRating = updatedReviews.reduce((sum, r) => sum + r.rating, 0) / updatedReviews.length
const newAvgRating = Number(avgRating.toFixed(1))
updateAppRating(appId, newAvgRating)
onRatingUpdate(newAvgRating, updatedReviews.length)
} else {
updateAppRating(appId, 0)
onRatingUpdate(0, 0)
}
}
const handleSubmitReview = async () => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
const review: Review = {
id: `r${Date.now()}`,
userId: user.id,
userName: user.name,
userAvatar: user.avatar,
userDepartment: user.department,
rating: newRating,
comment: newComment.trim(),
createdAt: new Date().toISOString(),
helpful: 0,
notHelpful: 0,
userHelpfulVotes: [],
userNotHelpfulVotes: [],
}
const updatedReviews = [...reviews, review]
saveReviews(updatedReviews)
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
setIsSubmitting(false)
}
const handleEditReview = async (reviewId: string) => {
if (!user || !newComment.trim()) return
setIsSubmitting(true)
const updatedReviews = reviews.map((review) =>
review.id === reviewId
? {
...review,
rating: newRating,
comment: newComment.trim(),
updatedAt: new Date().toISOString(),
}
: review,
)
saveReviews(updatedReviews)
setEditingReview(null)
setNewComment("")
setNewRating(5)
setIsSubmitting(false)
}
const handleDeleteReview = async (reviewId: string) => {
const updatedReviews = reviews.filter((review) => review.id !== reviewId)
saveReviews(updatedReviews)
}
const handleHelpfulVote = (reviewId: string, isHelpful: boolean) => {
if (!user) return
const updatedReviews = reviews.map((review) => {
if (review.id !== reviewId) return review
const helpfulVotes = [...review.userHelpfulVotes]
const notHelpfulVotes = [...review.userNotHelpfulVotes]
if (isHelpful) {
if (helpfulVotes.includes(user.id)) {
// Remove helpful vote
const index = helpfulVotes.indexOf(user.id)
helpfulVotes.splice(index, 1)
} else {
// Add helpful vote and remove not helpful if exists
helpfulVotes.push(user.id)
const notHelpfulIndex = notHelpfulVotes.indexOf(user.id)
if (notHelpfulIndex > -1) {
notHelpfulVotes.splice(notHelpfulIndex, 1)
}
}
} else {
if (notHelpfulVotes.includes(user.id)) {
// Remove not helpful vote
const index = notHelpfulVotes.indexOf(user.id)
notHelpfulVotes.splice(index, 1)
} else {
// Add not helpful vote and remove helpful if exists
notHelpfulVotes.push(user.id)
const helpfulIndex = helpfulVotes.indexOf(user.id)
if (helpfulIndex > -1) {
helpfulVotes.splice(helpfulIndex, 1)
}
}
}
return {
...review,
helpful: helpfulVotes.length,
notHelpful: notHelpfulVotes.length,
userHelpfulVotes: helpfulVotes,
userNotHelpfulVotes: notHelpfulVotes,
}
})
saveReviews(updatedReviews)
}
const startEdit = (review: Review) => {
setEditingReview(review.id)
setNewRating(review.rating)
setNewComment(review.comment)
setShowReviewForm(true)
}
const cancelEdit = () => {
setEditingReview(null)
setNewComment("")
setNewRating(5)
setShowReviewForm(false)
}
const sortedReviews = [...reviews].sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
case "helpful":
return b.helpful - a.helpful
case "newest":
default:
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
}
})
const getInitials = (name: string) => {
return name.split("").slice(0, 2).join("").toUpperCase()
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("zh-TW", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
const renderStars = (rating: number, interactive = false, onRate?: (rating: number) => void) => {
return (
<div className="flex items-center space-x-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= rating ? "text-yellow-400 fill-current" : "text-gray-300"
} ${interactive ? "cursor-pointer hover:text-yellow-400" : ""}`}
onClick={() => interactive && onRate && onRate(star)}
/>
))}
</div>
)
}
return (
<div className="space-y-6">
{/* Review Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription>
{reviews.length > 0 ? (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{renderStars(Math.round(currentRating))}
<span className="font-semibold">{currentRating}</span>
<span className="text-gray-500">({reviews.length} )</span>
</div>
</div>
) : (
"尚無評價,成為第一個評價的用戶!"
)}
</CardDescription>
</CardHeader>
<CardContent>
{/* Rating Distribution */}
{reviews.length > 0 && (
<div className="space-y-2 mb-6">
{[5, 4, 3, 2, 1].map((rating) => {
const count = reviews.filter((r) => r.rating === rating).length
const percentage = (count / reviews.length) * 100
return (
<div key={rating} className="flex items-center space-x-3">
<div className="flex items-center space-x-1 w-12">
<span className="text-sm">{rating}</span>
<Star className="w-3 h-3 text-yellow-400 fill-current" />
</div>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="bg-yellow-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-sm text-gray-500 w-8">{count}</span>
</div>
)
})}
</div>
)}
{/* Add Review Button */}
{canReview && (
<Button
onClick={() => setShowReviewForm(true)}
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
>
<Star className="w-4 h-4 mr-2" />
</Button>
)}
{userReview && (
<Alert>
<MessageSquare className="h-4 w-4" />
<AlertDescription></AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Review Form */}
<Dialog open={showReviewForm} onOpenChange={setShowReviewForm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingReview ? "編輯評價" : "撰寫評價"}</DialogTitle>
<DialogDescription> {appName} 使</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block"></label>
{renderStars(newRating, true, setNewRating)}
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Textarea
placeholder="請分享您的使用體驗..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={4}
maxLength={500}
/>
<div className="text-xs text-gray-500 mt-1">{newComment.length}/500 </div>
</div>
<div className="flex space-x-3">
<Button
onClick={editingReview ? () => handleEditReview(editingReview) : handleSubmitReview}
disabled={isSubmitting || !newComment.trim()}
className="flex-1"
>
{isSubmitting ? "提交中..." : editingReview ? "更新評價" : "提交評價"}
</Button>
<Button variant="outline" onClick={cancelEdit}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Reviews List */}
{reviews.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle> ({reviews.length})</CardTitle>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500"></span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="text-sm border rounded px-2 py-1"
>
<option value="newest"></option>
<option value="oldest"></option>
<option value="helpful"></option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{sortedReviews.map((review, index) => (
<div key={review.id}>
<div className="flex items-start space-x-4">
<Avatar className="w-10 h-10">
<AvatarImage src={review.userAvatar || "/placeholder.svg"} alt={review.userName} />
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
{getInitials(review.userName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<span className="font-medium">{review.userName}</span>
<Badge variant="secondary" className="text-xs">
{review.userDepartment}
</Badge>
{renderStars(review.rating)}
</div>
{user?.id === review.userId && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startEdit(review)}>
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteReview(review.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className="text-gray-700">{review.comment}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center space-x-4">
<span>
{formatDate(review.createdAt)}
{review.updatedAt && " (已編輯)"}
</span>
</div>
{user && user.id !== review.userId && (
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, true)}
className={`text-xs ${
review.userHelpfulVotes.includes(user.id)
? "text-green-600 bg-green-50"
: "text-gray-500"
}`}
>
<ThumbsUp className="w-3 h-3 mr-1" />
({review.helpful})
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleHelpfulVote(review.id, false)}
className={`text-xs ${
review.userNotHelpfulVotes.includes(user.id)
? "text-red-600 bg-red-50"
: "text-gray-500"
}`}
>
<ThumbsDown className="w-3 h-3 mr-1" />
({review.notHelpful})
</Button>
</div>
)}
</div>
</div>
</div>
{index < sortedReviews.length - 1 && <Separator className="mt-6" />}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.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 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

59
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium 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 }

View File

@@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

50
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
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-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
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 }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

56
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,56 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
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 }

213
components/ui/calendar.tsx Normal file
View File

@@ -0,0 +1,213 @@
'use client'
import * as React from 'react'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: date =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next
),
month_caption: cn(
'flex items-center justify-center h-[--cell-size] w-full px-[--cell-size]',
defaultClassNames.month_caption
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-[--cell-size] gap-1.5',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-[--cell-size]',
defaultClassNames.week_number_header
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

79
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,79 @@
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-lg border bg-card text-card-foreground shadow-sm",
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(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
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 }

262
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

365
components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

153
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
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 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 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</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",
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-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

118
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

178
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,71 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
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-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
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-sm font-medium leading-none 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 }

236
components/ui/menubar.tsx Normal file
View File

@@ -0,0 +1,236 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

View File

@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

31
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,45 @@
"use client"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

145
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,145 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.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 data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

763
components/ui/sidebar.tsx Normal file
View File

@@ -0,0 +1,763 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

25
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

31
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

29
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

117
components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
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", 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-muted/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 transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&: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 [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm 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-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

113
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,113 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive: "destructive border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

24
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast"
import { useToast } from "@/hooks/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

45
components/ui/toggle.tsx Normal file
View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

Some files were not shown because too many files have changed in this diff Show More