建立檔案
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
296
CHATBOT_ANALYSIS.md
Normal 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
56
README-ENV.md
Normal 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
123
README-SCORING.md
Normal 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
670
SOFTWARE_SPECIFICATION.md
Normal 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
7
app/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AdminPanel } from "@/components/admin/admin-panel"
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminPanel />
|
||||
}
|
||||
195
app/admin/scoring-form-test/page.tsx
Normal file
195
app/admin/scoring-form-test/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
app/admin/scoring-test/page.tsx
Normal file
13
app/admin/scoring-test/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
app/admin/scoring/page.tsx
Normal file
13
app/admin/scoring/page.tsx
Normal 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
572
app/competition/page.tsx
Normal 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
100
app/globals.css
Normal 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
384
app/judge-scoring/page.tsx
Normal 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
33
app/layout.tsx
Normal 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
7
app/loading.tsx
Normal 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
1017
app/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
3
app/register/loading.tsx
Normal file
3
app/register/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Loading() {
|
||||
return null
|
||||
}
|
||||
408
app/register/page.tsx
Normal file
408
app/register/page.tsx
Normal 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
21
components.json
Normal 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"
|
||||
}
|
||||
476
components/admin/admin-layout.tsx
Normal file
476
components/admin/admin-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
components/admin/admin-panel.tsx
Normal file
39
components/admin/admin-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
663
components/admin/analytics-dashboard.tsx
Normal file
663
components/admin/analytics-dashboard.tsx
Normal 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">
|
||||
近7天CPU峰值達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>
|
||||
)
|
||||
}
|
||||
991
components/admin/app-management.tsx
Normal file
991
components/admin/app-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6585
components/admin/competition-management.tsx
Normal file
6585
components/admin/competition-management.tsx
Normal file
File diff suppressed because it is too large
Load Diff
170
components/admin/dashboard.tsx
Normal file
170
components/admin/dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
components/admin/judge-list-dialog.tsx
Normal file
99
components/admin/judge-list-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
744
components/admin/proposal-management.tsx
Normal file
744
components/admin/proposal-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
components/admin/scoring-link-dialog.tsx
Normal file
152
components/admin/scoring-link-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
857
components/admin/scoring-management.tsx
Normal file
857
components/admin/scoring-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
520
components/admin/system-settings.tsx
Normal file
520
components/admin/system-settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
855
components/admin/team-management.tsx
Normal file
855
components/admin/team-management.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1142
components/admin/user-management.tsx
Normal file
1142
components/admin/user-management.tsx
Normal file
File diff suppressed because it is too large
Load Diff
466
components/app-detail-dialog.tsx
Normal file
466
components/app-detail-dialog.tsx
Normal 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">2024年1月15日</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>
|
||||
)
|
||||
}
|
||||
668
components/app-submission-dialog.tsx
Normal file
668
components/app-submission-dialog.tsx
Normal 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="列出應用的主要功能和特色,例如: • 支援多語言對話 • 上下文理解能力 • 個性化回應..."
|
||||
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>
|
||||
)
|
||||
}
|
||||
168
components/auth/activity-records-dialog.tsx
Normal file
168
components/auth/activity-records-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
components/auth/forgot-password-dialog.tsx
Normal file
148
components/auth/forgot-password-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
134
components/auth/login-dialog.tsx
Normal file
134
components/auth/login-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
components/auth/profile-dialog.tsx
Normal file
193
components/auth/profile-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
183
components/auth/register-dialog.tsx
Normal file
183
components/auth/register-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
components/auth/settings-dialog.tsx
Normal file
125
components/auth/settings-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
196
components/auth/user-menu.tsx
Normal file
196
components/auth/user-menu.tsx
Normal 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
397
components/chat-bot.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
661
components/competition/award-detail-dialog.tsx
Normal file
661
components/competition/award-detail-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
731
components/competition/competition-detail-dialog.tsx
Normal file
731
components/competition/competition-detail-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
261
components/competition/judge-scoring-dialog.tsx
Normal file
261
components/competition/judge-scoring-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
779
components/competition/popularity-rankings.tsx
Normal file
779
components/competition/popularity-rankings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
477
components/competition/registration-dialog.tsx
Normal file
477
components/competition/registration-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
325
components/competition/team-detail-dialog.tsx
Normal file
325
components/competition/team-detail-dialog.tsx
Normal 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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
76
components/favorite-button.tsx
Normal file
76
components/favorite-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
components/favorites-page.tsx
Normal file
129
components/favorites-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
components/like-button.tsx
Normal file
98
components/like-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
470
components/reviews/review-system.tsx
Normal file
470
components/reviews/review-system.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal 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 }
|
||||
141
components/ui/alert-dialog.tsx
Normal file
141
components/ui/alert-dialog.tsx
Normal 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
59
components/ui/alert.tsx
Normal 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 }
|
||||
7
components/ui/aspect-ratio.tsx
Normal file
7
components/ui/aspect-ratio.tsx
Normal 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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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 }
|
||||
115
components/ui/breadcrumb.tsx
Normal file
115
components/ui/breadcrumb.tsx
Normal 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
56
components/ui/button.tsx
Normal 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
213
components/ui/calendar.tsx
Normal 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
79
components/ui/card.tsx
Normal 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
262
components/ui/carousel.tsx
Normal 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
365
components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
28
components/ui/checkbox.tsx
Normal file
28
components/ui/checkbox.tsx
Normal 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 }
|
||||
11
components/ui/collapsible.tsx
Normal file
11
components/ui/collapsible.tsx
Normal 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
153
components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
200
components/ui/context-menu.tsx
Normal file
200
components/ui/context-menu.tsx
Normal 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
122
components/ui/dialog.tsx
Normal 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
118
components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal 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
178
components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal 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 }
|
||||
71
components/ui/input-otp.tsx
Normal file
71
components/ui/input-otp.tsx
Normal 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
22
components/ui/input.tsx
Normal 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
26
components/ui/label.tsx
Normal 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
236
components/ui/menubar.tsx
Normal 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,
|
||||
}
|
||||
128
components/ui/navigation-menu.tsx
Normal file
128
components/ui/navigation-menu.tsx
Normal 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,
|
||||
}
|
||||
117
components/ui/pagination.tsx
Normal file
117
components/ui/pagination.tsx
Normal 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
31
components/ui/popover.tsx
Normal 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 }
|
||||
25
components/ui/progress.tsx
Normal file
25
components/ui/progress.tsx
Normal 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 }
|
||||
44
components/ui/radio-group.tsx
Normal file
44
components/ui/radio-group.tsx
Normal 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 }
|
||||
45
components/ui/resizable.tsx
Normal file
45
components/ui/resizable.tsx
Normal 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 }
|
||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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
145
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
22
components/ui/separator.tsx
Normal file
22
components/ui/separator.tsx
Normal 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
140
components/ui/sheet.tsx
Normal 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
763
components/ui/sidebar.tsx
Normal 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,
|
||||
}
|
||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
25
components/ui/slider.tsx
Normal 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
31
components/ui/sonner.tsx
Normal 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
29
components/ui/switch.tsx
Normal 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
117
components/ui/table.tsx
Normal 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
55
components/ui/tabs.tsx
Normal 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 }
|
||||
21
components/ui/textarea.tsx
Normal file
21
components/ui/textarea.tsx
Normal 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
113
components/ui/toast.tsx
Normal 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
24
components/ui/toaster.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
components/ui/toggle-group.tsx
Normal file
61
components/ui/toggle-group.tsx
Normal 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
45
components/ui/toggle.tsx
Normal 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
Reference in New Issue
Block a user