EnCoach Frontend v1 - Production-ready with full API integration
- 59 pages (52 original + 7 new adaptive learning pages) - 14 AI components wired to real backend endpoints - Real JWT authentication with 7 user roles - 21 API service modules with TanStack Query hooks - 14 TypeScript type definition files - Entity-scoped permission system - Universal adaptive learning engine pages (subjects, diagnostic, proficiency, learning plan, topic) - Admin taxonomy and resource management pages - All mock data removed, all pages connected to Odoo 19 REST API docs/ contains: - ENCOACH_UNIFIED_SRS.md (master product SRS) - ODOO_BACKEND_SRS_v3.md (backend developer handoff) - ENCOACH_PRODUCT_DESCRIPTION.md (stakeholder document) - UTAS_MASTER_PLAN.md (project timeline) - Supporting architecture and analysis documents Made-with: Cursor
This commit is contained in:
2
.env.development
Normal file
2
.env.development
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8069/api
|
||||||
|
VITE_APP_NAME=EnCoach
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8069/api
|
||||||
|
VITE_APP_NAME=EnCoach
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Welcome to your Lovable project
|
||||||
|
|
||||||
|
## Project info
|
||||||
|
|
||||||
|
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
|
||||||
|
|
||||||
|
## How can I edit this code?
|
||||||
|
|
||||||
|
There are several ways of editing your application.
|
||||||
|
|
||||||
|
**Use Lovable**
|
||||||
|
|
||||||
|
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
|
||||||
|
|
||||||
|
Changes made via Lovable will be committed automatically to this repo.
|
||||||
|
|
||||||
|
**Use your preferred IDE**
|
||||||
|
|
||||||
|
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
|
||||||
|
|
||||||
|
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
||||||
|
|
||||||
|
Follow these steps:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Step 1: Clone the repository using the project's Git URL.
|
||||||
|
git clone <YOUR_GIT_URL>
|
||||||
|
|
||||||
|
# Step 2: Navigate to the project directory.
|
||||||
|
cd <YOUR_PROJECT_NAME>
|
||||||
|
|
||||||
|
# Step 3: Install the necessary dependencies.
|
||||||
|
npm i
|
||||||
|
|
||||||
|
# Step 4: Start the development server with auto-reloading and an instant preview.
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit a file directly in GitHub**
|
||||||
|
|
||||||
|
- Navigate to the desired file(s).
|
||||||
|
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
||||||
|
- Make your changes and commit the changes.
|
||||||
|
|
||||||
|
**Use GitHub Codespaces**
|
||||||
|
|
||||||
|
- Navigate to the main page of your repository.
|
||||||
|
- Click on the "Code" button (green button) near the top right.
|
||||||
|
- Select the "Codespaces" tab.
|
||||||
|
- Click on "New codespace" to launch a new Codespace environment.
|
||||||
|
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
||||||
|
|
||||||
|
## What technologies are used for this project?
|
||||||
|
|
||||||
|
This project is built with:
|
||||||
|
|
||||||
|
- Vite
|
||||||
|
- TypeScript
|
||||||
|
- React
|
||||||
|
- shadcn-ui
|
||||||
|
- Tailwind CSS
|
||||||
|
|
||||||
|
## How can I deploy this project?
|
||||||
|
|
||||||
|
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
||||||
|
|
||||||
|
## Can I connect a custom domain to my Lovable project?
|
||||||
|
|
||||||
|
Yes, you can!
|
||||||
|
|
||||||
|
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
||||||
|
|
||||||
|
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
|
||||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
633
docs/04-AI-Stack-Report.md
Normal file
633
docs/04-AI-Stack-Report.md
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
# EnCoach - AI Stack Technical Report
|
||||||
|
|
||||||
|
**Analysis Date:** March 8, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [AI Stack Overview](#1-ai-stack-overview)
|
||||||
|
2. [OpenAI GPT (Content Generation & Grading)](#2-openai-gpt--content-generation--grading)
|
||||||
|
3. [OpenAI Whisper (Speech-to-Text)](#3-openai-whisper--speech-to-text)
|
||||||
|
4. [AWS Polly (Text-to-Speech)](#4-aws-polly--text-to-speech)
|
||||||
|
5. [FAISS + Sentence Transformers (RAG Training Tips)](#5-faiss--sentence-transformers--rag-training-tips)
|
||||||
|
6. [ELAI (AI Avatar Video Generation)](#6-elai--ai-avatar-video-generation)
|
||||||
|
7. [GPTZero (AI Writing Detection)](#7-gptzero--ai-writing-detection)
|
||||||
|
8. [End-to-End Data Flows](#8-end-to-end-data-flows)
|
||||||
|
9. [Frontend AI Integration Points](#9-frontend-ai-integration-points)
|
||||||
|
10. [Environment Variables & Configuration](#10-environment-variables--configuration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AI Stack Overview
|
||||||
|
|
||||||
|
The EnCoach platform uses **6 AI/ML services** working together to power automated IELTS exam generation, grading, and personalized training.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (Next.js) │
|
||||||
|
│ ExamEditor, ExamPage, Training, AIDetection components │
|
||||||
|
└────────────────────────┬────────────────────────────────┘
|
||||||
|
│ HTTP (JWT)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend (FastAPI) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ OpenAI │ │ Whisper │ │ AWS Polly│ │
|
||||||
|
│ │ GPT-4o │ │ (local) │ │ (cloud) │ │
|
||||||
|
│ │ GPT-3.5 │ │ base │ │ neural │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ Content Gen Transcription TTS Audio │
|
||||||
|
│ Grading Speaking eval Listening MP3s │
|
||||||
|
│ Evaluation │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ FAISS + │ │ ELAI │ │ GPTZero │ │
|
||||||
|
│ │ SentTrans│ │ (cloud) │ │ (cloud) │ │
|
||||||
|
│ │ (local) │ │ avatars │ │ detect │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ RAG Tips Avatar Videos AI Detection │
|
||||||
|
│ Training Speaking Writing eval │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | Type | Purpose | Model/Version |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **OpenAI GPT** | Cloud API | Content generation, grading, evaluation | `gpt-4o`, `gpt-3.5-turbo` |
|
||||||
|
| **OpenAI Whisper** | Local (self-hosted) | Speech-to-text transcription | `base` (~1 GB, 4 instances) |
|
||||||
|
| **AWS Polly** | Cloud API | Text-to-speech for listening | Neural engine, 11 voices |
|
||||||
|
| **FAISS + Sentence Transformers** | Local (self-hosted) | RAG-based training tips | `all-MiniLM-L6-v2` + `IndexFlatL2` |
|
||||||
|
| **ELAI** | Cloud API | AI avatar video generation | ElevenLabs/Azure voices |
|
||||||
|
| **GPTZero** | Cloud API | AI-generated text detection | v2/predict/text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. OpenAI GPT — Content Generation & Grading
|
||||||
|
|
||||||
|
### 2.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Default Model** | `gpt-4o` |
|
||||||
|
| **Secondary Model** | `gpt-3.5-turbo` |
|
||||||
|
| **Max Tokens** | 4,097 (300 reserved) |
|
||||||
|
| **Response Format** | `json_object` |
|
||||||
|
| **Retry Limit** | 2 (on blacklist or missing fields) |
|
||||||
|
| **Content Filter** | Blacklisted words (religious, sexual, political terms) |
|
||||||
|
|
||||||
|
**Temperature settings:**
|
||||||
|
|
||||||
|
| Context | Temperature | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Grading | 0.1 | Near-deterministic, consistent evaluation |
|
||||||
|
| Tips / Summaries | 0.2 | Low creativity, factual output |
|
||||||
|
| Content Generation | 0.7 | Higher creativity for passages and tasks |
|
||||||
|
|
||||||
|
### 2.2 Content Generation
|
||||||
|
|
||||||
|
GPT generates all IELTS exam content. Each module has specific prompt templates:
|
||||||
|
|
||||||
|
#### Reading Module
|
||||||
|
- **Model:** `gpt-4o`, temperature 0.7
|
||||||
|
- **Generates:** Passages with title and text body
|
||||||
|
- **Difficulty scaling:**
|
||||||
|
- Passage 1: easy
|
||||||
|
- Passage 2: hard
|
||||||
|
- Passage 3: very hard
|
||||||
|
- **Exercise types generated:** Fill in the blanks, True/False/Not Given, Matching headings, Multiple choice
|
||||||
|
- **Prompt pattern:** System prompt defines JSON schema → User prompt specifies passage difficulty, topic, and word count
|
||||||
|
|
||||||
|
#### Listening Module
|
||||||
|
- **Model:** `gpt-4o`, temperature 0.7
|
||||||
|
- **Generates:** Conversation scripts and monologues
|
||||||
|
- **Output format:**
|
||||||
|
- Conversations: `{"conversation": [{"name", "gender", "text"}]}` — 2 or 4 speakers
|
||||||
|
- Monologues: `{"monologue": "..."}` — social or academic context
|
||||||
|
- **Exercise types generated:** Same as reading (fill blanks, T/F/NG, matching, MC)
|
||||||
|
|
||||||
|
#### Writing Module
|
||||||
|
- **Task 1 (General):** Letter prompts — `gpt-3.5-turbo`
|
||||||
|
- **Task 1 (Academic):** Image-based prompts — `gpt-4o`
|
||||||
|
- **Task 2 (Essay):** Essay prompts — `gpt-4o`
|
||||||
|
|
||||||
|
#### Speaking Module
|
||||||
|
- **Model:** `gpt-4o`, temperature 0.7
|
||||||
|
- **Part 1:** 5 questions across 2 topics
|
||||||
|
- **Part 2:** 1 question + 3 follow-up prompts
|
||||||
|
- **Part 3:** 5 discussion questions
|
||||||
|
|
||||||
|
#### Level Test
|
||||||
|
- **Generates:** Multiple choice questions at varying difficulty levels
|
||||||
|
- **Supports:** Standard level, UTAS format, custom levels
|
||||||
|
|
||||||
|
### 2.3 Grading / Evaluation
|
||||||
|
|
||||||
|
GPT evaluates student answers using IELTS band scoring criteria:
|
||||||
|
|
||||||
|
#### Writing Grading
|
||||||
|
- **Model:** `gpt-4o`, temperature 0.1 (Task 1) / 0.7 (Task 2)
|
||||||
|
- **Runs in parallel:** Evaluation + Perfect Answer + Spelling Fix + GPTZero
|
||||||
|
- **Output JSON:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"comment": "Detailed commentary...",
|
||||||
|
"overall": 6.5,
|
||||||
|
"task_response": {
|
||||||
|
"Task Achievement": { "score": 7, "comment": "..." },
|
||||||
|
"Coherence and Cohesion": { "score": 6, "comment": "..." },
|
||||||
|
"Lexical Resource": { "score": 7, "comment": "..." },
|
||||||
|
"Grammatical Range and Accuracy": { "score": 6, "comment": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Perfect Answer:** GPT generates an ideal answer for comparison
|
||||||
|
- **Spelling Fix:** `gpt-3.5-turbo` at temperature 0.2 corrects transcription errors
|
||||||
|
|
||||||
|
#### Speaking Grading
|
||||||
|
- **Flow:** Audio → Whisper transcription → GPT-4o grading
|
||||||
|
- **Criteria:** Fluency & Coherence, Lexical Resource, Grammar, Pronunciation
|
||||||
|
- **Perfect Answers:** `gpt-4o` (Part 1), `gpt-3.5-turbo` (Parts 2/3)
|
||||||
|
|
||||||
|
#### Exam Summary
|
||||||
|
- **Model:** `gpt-3.5-turbo`, temperature 0.2
|
||||||
|
- **Uses:** OpenAI function calling (`save_evaluation_and_suggestions`)
|
||||||
|
- **Output:** Overall evaluation, suggestions, bullet points
|
||||||
|
|
||||||
|
### 2.4 Other GPT Uses
|
||||||
|
- **Training tips selection:** GPT selects relevant tips from FAISS results
|
||||||
|
- **Whisper overlap fix:** GPT-4o merges overlapping transcription segments
|
||||||
|
- **Short answer grading:** Grades fill-in-the-blank and short text answers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OpenAI Whisper — Speech-to-Text
|
||||||
|
|
||||||
|
### 3.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Model** | `base` (~1 GB) |
|
||||||
|
| **Instances** | 4 (round-robin) |
|
||||||
|
| **Loading** | `in_memory=True` |
|
||||||
|
| **Concurrency** | `ThreadPoolExecutor` with 4 workers |
|
||||||
|
| **Language** | English |
|
||||||
|
| **Precision** | `fp16=False` |
|
||||||
|
|
||||||
|
### 3.2 How It Works
|
||||||
|
|
||||||
|
Whisper runs **locally** on the Cloud Run container (not via OpenAI's API). Four model instances are loaded into memory at startup and assigned to workers via round-robin.
|
||||||
|
|
||||||
|
**Processing pipeline:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Audio Input
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Resample to 16 kHz mono float32 (librosa)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Split into 30-second chunks (1/4 overlap)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Transcribe each chunk (Whisper base model)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Concatenate transcriptions
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Fix overlapping text (GPT-4o removes duplicated words)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Final transcript
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retry:** 3 attempts via `tenacity` library.
|
||||||
|
|
||||||
|
### 3.3 Where It's Connected
|
||||||
|
|
||||||
|
| Feature | Connection |
|
||||||
|
|---|---|
|
||||||
|
| **Speaking Grading** | Student audio → Whisper → transcript → GPT grading |
|
||||||
|
| **Audio Transcription** | Uploaded audio → Whisper → listening script for exam editor |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AWS Polly — Text-to-Speech
|
||||||
|
|
||||||
|
### 4.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Engine** | Neural |
|
||||||
|
| **Output Format** | MP3 |
|
||||||
|
| **Region** | `eu-west-1` |
|
||||||
|
| **Max Chunk Size** | 3,000 characters (split at sentence boundaries) |
|
||||||
|
|
||||||
|
### 4.2 Available Voices
|
||||||
|
|
||||||
|
| Voice | Language/Accent |
|
||||||
|
|---|---|
|
||||||
|
| Danielle | American English |
|
||||||
|
| Gregory | American English |
|
||||||
|
| Kevin | American English |
|
||||||
|
| Ruth | American English |
|
||||||
|
| Stephen | American English |
|
||||||
|
| Arthur | British English |
|
||||||
|
| Olivia | Australian English |
|
||||||
|
| Ayanda | South African English |
|
||||||
|
| Aria | New Zealand English |
|
||||||
|
| Kajal | Indian English |
|
||||||
|
| Niamh | Irish English |
|
||||||
|
|
||||||
|
### 4.3 How It Works
|
||||||
|
|
||||||
|
**Listening exam audio generation:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Generated Dialog/Monologue (from GPT)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Assign voices to speakers (random for monologue, per-speaker for dialog)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Split text into ≤3000 char chunks at sentence boundaries
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AWS Polly Neural TTS → MP3 audio bytes
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Concatenate audio segments
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Upload to Firebase Storage
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return download URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Where It's Connected
|
||||||
|
|
||||||
|
| Feature | Connection |
|
||||||
|
|---|---|
|
||||||
|
| **Listening Exam Audio** | GPT script → Polly TTS → MP3 → Firebase Storage → student playback |
|
||||||
|
| **Listening Instructions** | "Recording has now finished" scripts → Stephen voice → MP3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. FAISS + Sentence Transformers — RAG Training Tips
|
||||||
|
|
||||||
|
### 5.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Embeddings Model** | `all-MiniLM-L6-v2` (Sentence Transformers) |
|
||||||
|
| **Index Type** | `faiss.IndexFlatL2` (exact L2 search) |
|
||||||
|
| **Top-K** | 5 results per query |
|
||||||
|
| **Data Source** | `pathways_2_rw_with_ids.json` |
|
||||||
|
|
||||||
|
### 5.2 Knowledge Base Categories
|
||||||
|
|
||||||
|
| Category | Index File |
|
||||||
|
|---|---|
|
||||||
|
| `ct_focus` | `./faiss/ct_focus_tips_index.faiss` |
|
||||||
|
| `language_for_writing` | `./faiss/language_for_writing_tips_index.faiss` |
|
||||||
|
| `reading_skill` | `./faiss/reading_skill_tips_index.faiss` |
|
||||||
|
| `strategy` | `./faiss/strategy_tips_index.faiss` |
|
||||||
|
| `word_link` | `./faiss/word_link_tips_index.faiss` |
|
||||||
|
| `word_partners` | `./faiss/word_partners_tips_index.faiss` |
|
||||||
|
| `writing_skill` | `./faiss/writing_skill_tips_index.faiss` |
|
||||||
|
|
||||||
|
**Metadata:** `./faiss/tips_metadata.pkl` (pickle file with tip IDs and text)
|
||||||
|
|
||||||
|
### 5.3 How RAG Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Student Exam Performance
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT analyzes performance → generates queries (text + category)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Sentence Transformers encodes query → embedding vector
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FAISS L2 search → top 5 matching tips per category
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT selects most relevant tips from retrieved results
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Tips stored in MongoDB and displayed to student
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Where It's Connected
|
||||||
|
|
||||||
|
| Feature | Connection |
|
||||||
|
|---|---|
|
||||||
|
| **Training Module** | After exam → analyze weak areas → retrieve personalized tips → display training content |
|
||||||
|
| **Walkthrough** | Tips linked to specific reading/writing skills for guided learning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ELAI — AI Avatar Video Generation
|
||||||
|
|
||||||
|
### 6.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **API Endpoint** | `https://apis.elai.io/api/v1/videos` |
|
||||||
|
| **Auth** | Bearer token (`ELAI_TOKEN`) |
|
||||||
|
| **Animation** | `fade_in` |
|
||||||
|
| **Language** | English |
|
||||||
|
|
||||||
|
### 6.2 Available Avatars
|
||||||
|
|
||||||
|
| Avatar | Gender | Voice Provider |
|
||||||
|
|---|---|---|
|
||||||
|
| Gia | Female | ElevenLabs |
|
||||||
|
| Vadim | Male | Azure |
|
||||||
|
| Orhan | Male | ElevenLabs |
|
||||||
|
| Flora | Female | Azure |
|
||||||
|
| Scarlett | Female | ElevenLabs |
|
||||||
|
| Parker | Male | Azure |
|
||||||
|
| Ethan | Male | ElevenLabs |
|
||||||
|
|
||||||
|
Each avatar has a unique `avatar_code`, `avatar_url`, `avatar_canvas` dimensions, `voice_id`, and `voice_provider`.
|
||||||
|
|
||||||
|
### 6.3 How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Speaking Task Text (from GPT)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Select Avatar (user choice from available list)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Build video config (slide, avatar, canvas, logo, voice settings)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST to ELAI API → create video
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST render request → start processing
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Poll GET status every 10 seconds until "ready"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return video URL for playback
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Where It's Connected
|
||||||
|
|
||||||
|
| Feature | Connection |
|
||||||
|
|---|---|
|
||||||
|
| **Speaking Exam** | AI avatar presents speaking questions via video |
|
||||||
|
| **Exam Editor** | Teachers generate speaking videos while creating exams |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. GPTZero — AI Writing Detection
|
||||||
|
|
||||||
|
### 7.1 Configuration
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---|---|
|
||||||
|
| **API Endpoint** | `https://api.gptzero.me/v2/predict/text` |
|
||||||
|
| **Auth** | `x-api-key` header |
|
||||||
|
| **Multilingual** | `false` |
|
||||||
|
|
||||||
|
### 7.2 How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Student's Writing Submission
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST to GPTZero API with document text
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response: predicted_class, confidence, per-sentence AI probability
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Returned as part of writing evaluation
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend displays AI Detection component
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response fields:**
|
||||||
|
- `predicted_class`: `ai`, `mixed`, or `human`
|
||||||
|
- `confidence_category`: confidence level
|
||||||
|
- `class_probabilities`: probability distribution
|
||||||
|
- `sentences`: per-sentence analysis with `highlight_sentence_for_ai` flag
|
||||||
|
|
||||||
|
### 7.3 Where It's Connected
|
||||||
|
|
||||||
|
| Feature | Connection |
|
||||||
|
|---|---|
|
||||||
|
| **Writing Grading** | Runs in parallel with GPT evaluation, perfect answer, and spelling fix |
|
||||||
|
| **Frontend UI** | `AIDetection` component displays results with radial progress, segmented bars, and highlighted AI-generated sentences |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. End-to-End Data Flows
|
||||||
|
|
||||||
|
### 8.1 Exam Generation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Teacher clicks "Generate" in Exam Editor
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend: POST /api/exam/generate/{module}/{sectionId}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Next.js API Route: proxies to BACKEND_URL/{module}/...
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FastAPI Controller → Service
|
||||||
|
│
|
||||||
|
├── Reading: GPT-4o generates passage + exercises
|
||||||
|
├── Listening: GPT-4o generates script → Polly TTS → MP3 → Firebase
|
||||||
|
├── Writing: GPT-4o/3.5 generates task prompt
|
||||||
|
└── Speaking: GPT-4o generates questions → ELAI creates avatar video
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response with generated content → stored in exam editor state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Writing Grading Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Student submits writing answer
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend: POST /api/evaluate/writing
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Next.js API: inserts pending evaluation in MongoDB → proxies to backend
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FastAPI runs 4 tasks IN PARALLEL:
|
||||||
|
├── GPT-4o: Grade against IELTS band criteria (temp 0.1)
|
||||||
|
├── GPT-4o: Generate perfect answer for comparison
|
||||||
|
├── GPT-3.5: Fix spelling/transcription errors (temp 0.2)
|
||||||
|
└── GPTZero: Detect AI-generated content
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Combined result stored in MongoDB evaluation collection
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend polls /api/evaluate/status until complete
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
UI shows: band scores, detailed comments, perfect answer, AI detection
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Speaking Grading Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Student records audio response
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend: POST /api/evaluate/speaking (FormData with audio)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Next.js API: inserts pending evaluation → proxies to backend
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FastAPI pipeline:
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Whisper (local): Transcribe audio → text
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT-4o: Grade transcript against IELTS speaking criteria (temp 0.1)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT-4o/3.5: Generate perfect answer
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Result stored in MongoDB
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend polls and displays scores + transcript + perfect answer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 Training / Personalized Tips Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Student completes an exam
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Frontend: POST /api/training
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend analyzes exam performance with GPT
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT generates search queries + categories
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Sentence Transformers encodes queries → FAISS L2 search
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Top 5 tips per category retrieved
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GPT selects most relevant tips
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Training content stored in MongoDB → displayed to student
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Frontend AI Integration Points
|
||||||
|
|
||||||
|
### 9.1 API Routes (Next.js → FastAPI)
|
||||||
|
|
||||||
|
| Frontend Route | Backend Route | AI Services Used |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/evaluate/writing` | `BACKEND_URL/grade/writing/{task}` | GPT-4o, GPT-3.5, GPTZero |
|
||||||
|
| `POST /api/evaluate/speaking` | `BACKEND_URL/grade/speaking/{task}` | Whisper, GPT-4o, GPT-3.5 |
|
||||||
|
| `GET /api/exam/generate/reading/{id}` | `BACKEND_URL/reading/{passage}` | GPT-4o |
|
||||||
|
| `GET /api/exam/generate/listening/{id}` | `BACKEND_URL/listening/{section}` | GPT-4o |
|
||||||
|
| `GET /api/exam/generate/writing/{id}` | `BACKEND_URL/writing/{task}` | GPT-4o / GPT-3.5 |
|
||||||
|
| `GET /api/exam/generate/speaking/{id}` | `BACKEND_URL/speaking/{task}` | GPT-4o |
|
||||||
|
| `POST /api/exam/media/listening` | `BACKEND_URL/listening/media` | AWS Polly |
|
||||||
|
| `POST /api/exam/media/speaking` | `BACKEND_URL/speaking/media` | ELAI |
|
||||||
|
| `POST /api/transcribe` | `BACKEND_URL/listening/transcribe` | Whisper |
|
||||||
|
| `POST /api/training` | `BACKEND_URL/training/` | GPT, FAISS, Sentence Transformers |
|
||||||
|
| `GET /api/exam/avatars` | `BACKEND_URL/speaking/avatars` | ELAI |
|
||||||
|
|
||||||
|
### 9.2 Key UI Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `AIDetection.tsx` | Displays AI detection results (radial progress, highlighted sentences) |
|
||||||
|
| `GenerateBtn.tsx` | Brain icon button with spinner for content generation |
|
||||||
|
| `generateVideos.ts` | Manages ELAI video creation + polling loop |
|
||||||
|
| `ExamPage.tsx` | Triggers writing/speaking evaluation + polls for results |
|
||||||
|
| `useEvaluationPolling.tsx` | Hook that polls evaluation status until grading completes |
|
||||||
|
| `generation.tsx` | Page for generating exams (gated by permissions) |
|
||||||
|
|
||||||
|
### 9.3 Permissions
|
||||||
|
|
||||||
|
| Permission | Controls |
|
||||||
|
|---|---|
|
||||||
|
| `generate_reading` | Reading passage generation |
|
||||||
|
| `generate_listening` | Listening script generation |
|
||||||
|
| `generate_writing` | Writing prompt generation |
|
||||||
|
| `generate_speaking` | Speaking task generation |
|
||||||
|
| `generate_level` | Level test generation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Environment Variables & Configuration
|
||||||
|
|
||||||
|
### 10.1 Required API Keys
|
||||||
|
|
||||||
|
| Variable | Service | Where Used |
|
||||||
|
|---|---|---|
|
||||||
|
| `OPENAI_API_KEY` | OpenAI GPT-4o / GPT-3.5 | Content generation, grading, evaluation |
|
||||||
|
| `AWS_ACCESS_KEY_ID` | AWS Polly | Text-to-speech |
|
||||||
|
| `AWS_SECRET_ACCESS_KEY` | AWS Polly | Text-to-speech |
|
||||||
|
| `ELAI_TOKEN` | ELAI | Avatar video generation |
|
||||||
|
| `GPT_ZERO_API_KEY` | GPTZero | AI writing detection |
|
||||||
|
|
||||||
|
### 10.2 Backend Service Wiring (Dependency Injection)
|
||||||
|
|
||||||
|
```
|
||||||
|
DI Container
|
||||||
|
├── llm → OpenAI(client=AsyncOpenAI)
|
||||||
|
├── tts → AWSPolly(client=polly_client)
|
||||||
|
├── stt → OpenAIWhisper(model="base", num_models=4)
|
||||||
|
├── vid_gen → ELAI(client=http_client, token, avatars, conf)
|
||||||
|
├── ai_detector → GPTZero(client=http_client, key)
|
||||||
|
├── training_kb → TrainingContentKnowledgeBase(embeddings=SentenceTransformer)
|
||||||
|
│
|
||||||
|
├── Controllers
|
||||||
|
│ ├── ReadingController → uses llm
|
||||||
|
│ ├── ListeningController → uses llm, tts
|
||||||
|
│ ├── WritingController → uses llm, ai_detector
|
||||||
|
│ ├── SpeakingController → uses llm, stt, vid_gen
|
||||||
|
│ ├── GradeController → uses llm, stt, ai_detector
|
||||||
|
│ ├── LevelController → uses llm
|
||||||
|
│ └── TrainingController → uses llm, training_kb
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Cost Drivers
|
||||||
|
|
||||||
|
| Service | Cost Model | Usage Pattern |
|
||||||
|
|---|---|---|
|
||||||
|
| **OpenAI GPT-4o** | Per token (input + output) | Every generation, every grading — highest cost |
|
||||||
|
| **OpenAI GPT-3.5** | Per token (cheaper) | Summaries, spelling, some writing tasks |
|
||||||
|
| **AWS Polly** | Per character (Neural) | Every listening exam audio |
|
||||||
|
| **ELAI** | Per video minute | Every speaking exam video |
|
||||||
|
| **GPTZero** | Per API call | Every writing grading |
|
||||||
|
| **Whisper (local)** | Compute only (no API cost) | Every speaking grading + transcription |
|
||||||
|
| **FAISS (local)** | Compute only (no API cost) | Every training session |
|
||||||
344
docs/ARCHITECTURE.md
Normal file
344
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
# EnCoach Platform -- Architecture & Connectivity Document
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
EnCoach is an **IELTS preparation and English proficiency testing platform** composed of four repositories that work together as a distributed system. The platform supports exam taking, AI-powered grading, training content, multi-tenant entity management, payments, and a public-facing marketing website.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repositories at a Glance
|
||||||
|
|
||||||
|
- **[ielts-ui](ielts-ui)** -- Full-stack Next.js 14 app. The main platform for students, teachers, admins, and corporate users. Serves both the browser UI and internal API routes.
|
||||||
|
- **[ielts-be](ielts-be)** -- Python/FastAPI backend. Handles AI/ML workloads: exam generation, grading (writing/speaking), audio transcription, TTS, and training content.
|
||||||
|
- **[encoachcms](encoachcms)** -- Strapi v4 headless CMS. Manages bilingual (EN/AR) marketing content for the landing page.
|
||||||
|
- **[encoach-landing-page](encoach-landing-page)** -- Next.js 13 marketing website. Consumes CMS content and links users into the main platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph publicFacing ["Public-Facing Layer"]
|
||||||
|
LP["encoach-landing-page<br/>(Next.js 13)"]
|
||||||
|
CMS["encoachcms<br/>(Strapi v4)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph platformCore ["Platform Core"]
|
||||||
|
UI["ielts-ui<br/>(Next.js 14 + API Routes)"]
|
||||||
|
BE["ielts-be<br/>(FastAPI / Python)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph dataStores ["Data Stores"]
|
||||||
|
MongoDB["MongoDB"]
|
||||||
|
MySQL["MySQL<br/>(CMS)"]
|
||||||
|
FirebaseAuth["Firebase Auth"]
|
||||||
|
FirebaseStorage["Firebase Storage"]
|
||||||
|
GCS["Google Cloud Storage<br/>(CMS uploads)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph externalServices ["External Services"]
|
||||||
|
OpenAI["OpenAI GPT-4o"]
|
||||||
|
Whisper["OpenAI Whisper<br/>(local)"]
|
||||||
|
Polly["AWS Polly<br/>(TTS)"]
|
||||||
|
ELAI["ELAI<br/>(AI Video)"]
|
||||||
|
GPTZero["GPTZero<br/>(AI Detection)"]
|
||||||
|
Stripe["Stripe"]
|
||||||
|
PayPal["PayPal"]
|
||||||
|
Paymob["Paymob"]
|
||||||
|
end
|
||||||
|
|
||||||
|
LP -->|"REST + Bearer token"| CMS
|
||||||
|
LP -->|"GET /api/packages, POST /api/tickets"| UI
|
||||||
|
LP -->|"Links to platform.encoach.com"| UI
|
||||||
|
|
||||||
|
CMS --> MySQL
|
||||||
|
CMS --> GCS
|
||||||
|
|
||||||
|
UI --> MongoDB
|
||||||
|
UI --> FirebaseAuth
|
||||||
|
UI -->|"Proxy: Bearer JWT"| BE
|
||||||
|
|
||||||
|
BE --> MongoDB
|
||||||
|
BE --> FirebaseAuth
|
||||||
|
BE --> FirebaseStorage
|
||||||
|
BE --> OpenAI
|
||||||
|
BE --> Whisper
|
||||||
|
BE --> Polly
|
||||||
|
BE --> ELAI
|
||||||
|
BE --> GPTZero
|
||||||
|
|
||||||
|
UI --> Stripe
|
||||||
|
UI --> PayPal
|
||||||
|
UI --> Paymob
|
||||||
|
LP --> Stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ielts-ui (Main Platform)
|
||||||
|
|
||||||
|
**Tech:** Next.js 14, React 18, TypeScript, Tailwind CSS + DaisyUI, Zustand, SWR, iron-session, Firebase Auth, MongoDB (direct)
|
||||||
|
|
||||||
|
**Role:** The central application. Serves the browser UI and acts as a **BFF (Backend-for-Frontend)** via Next.js API routes. It directly queries MongoDB for user/entity/group data and proxies AI-related requests to `ielts-be`.
|
||||||
|
|
||||||
|
### Key Modules
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Auth** | Firebase email/password auth + iron-session cookies. SSR-protected via `withIronSessionSsr`. |
|
||||||
|
| **Dashboards** | Role-based: student, teacher, admin, corporate, mastercorporate, developer, agent. |
|
||||||
|
| **Exams** | Taking exams (Reading, Listening, Writing, Speaking, Level). Zustand store tracks session, progress, solutions. |
|
||||||
|
| **Exam Editor** | Create/edit exams with dedicated components under `src/components/ExamEditor/`. |
|
||||||
|
| **Training** | Training content consumed from `ielts-be`. |
|
||||||
|
| **Grading** | Writing and speaking grading proxied to `ielts-be` as background tasks; UI polls for results. |
|
||||||
|
| **Entities** | Multi-tenant entity management (schools, organizations). |
|
||||||
|
| **Classrooms** | Classroom and group management for teachers. |
|
||||||
|
| **Assignments** | Assign exams to students with deadlines. |
|
||||||
|
| **Stats** | Performance statistics and PDF report generation (via `@react-pdf/renderer`). |
|
||||||
|
| **Payments** | Stripe, PayPal, Paymob webhook handlers in API routes. |
|
||||||
|
| **Tickets** | Support ticket system. |
|
||||||
|
| **User Mgmt** | Bulk import, permissions, role-based access. |
|
||||||
|
|
||||||
|
### How it Connects to ielts-be
|
||||||
|
|
||||||
|
API routes in `src/pages/api/` proxy to `BACKEND_URL` with `Authorization: Bearer ${BACKEND_JWT}`:
|
||||||
|
|
||||||
|
| UI API Route | Backend Endpoint |
|
||||||
|
|--------------|------------------|
|
||||||
|
| `/api/evaluate/writing` | `BACKEND_URL/grade/writing/{task}` |
|
||||||
|
| `/api/evaluate/speaking` | `BACKEND_URL/grade/speaking/{task}` |
|
||||||
|
| `/api/transcribe` | `BACKEND_URL/listening/transcribe` |
|
||||||
|
| `/api/training` | `BACKEND_URL/training/` |
|
||||||
|
| `/api/exam/upload` | `BACKEND_URL/{endpoint}` |
|
||||||
|
| `/api/exam/media/*` | `BACKEND_URL/{endpoint}/media` |
|
||||||
|
| `/api/exam/generate/*` | `BACKEND_URL/{endpoint}` |
|
||||||
|
| `/api/exam/avatars` | `BACKEND_URL/speaking/avatars` |
|
||||||
|
| `/api/exam/[module]/import` | `BACKEND_URL/{module}/import` |
|
||||||
|
| `/api/stats/[id]/[export]/pdf` | `BACKEND_URL/grade/summary` |
|
||||||
|
| `/api/batch_users` | `BACKEND_URL/user/import` |
|
||||||
|
|
||||||
|
### How it Connects to the Landing Page
|
||||||
|
|
||||||
|
Exposes public-ish API routes consumed by the landing page:
|
||||||
|
|
||||||
|
- `GET /api/packages` -- subscription packages (CORS-enabled)
|
||||||
|
- `POST /api/tickets` -- contact form submissions (CORS-enabled)
|
||||||
|
- `GET /api/users/agents` -- list sales agents (CORS-enabled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ielts-be (AI/ML Backend)
|
||||||
|
|
||||||
|
**Tech:** Python 3.11, FastAPI, Motor (async MongoDB), Poetry, dependency-injector, OpenAI, Whisper, AWS Polly, ELAI, FAISS
|
||||||
|
|
||||||
|
**Role:** Handles all AI/ML-intensive operations -- exam content generation, grading, transcription, text-to-speech, AI video, and training recommendations. Runs as a standalone service on port 8000.
|
||||||
|
|
||||||
|
### Architecture Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
API Routes --> Controllers --> Services --> Repositories
|
||||||
|
|
|
||||||
|
Third-Party Services
|
||||||
|
(OpenAI, Whisper, Polly, ELAI, GPTZero)
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired together via `dependency-injector`.
|
||||||
|
|
||||||
|
### Key Capabilities
|
||||||
|
|
||||||
|
| Capability | Implementation |
|
||||||
|
|------------|----------------|
|
||||||
|
| **Writing Grading** | GPT-4o evaluates against IELTS rubric (Task Achievement, Coherence, Lexical Resource, Grammar). GPTZero checks for AI-generated text. Runs as background task; results stored in `evaluation` collection. |
|
||||||
|
| **Speaking Grading** | Whisper transcribes audio, then GPT grades (Fluency, Lexical, Grammar, Pronunciation). Parts 1/2/3 supported. |
|
||||||
|
| **Exam Generation** | GPT generates reading passages, listening dialogs, writing prompts, speaking tasks, and level-test exercises. |
|
||||||
|
| **Audio (TTS)** | AWS Polly synthesizes MP3 for listening sections. |
|
||||||
|
| **Video** | ELAI generates AI avatar videos for speaking prompts. |
|
||||||
|
| **Transcription** | Whisper (local model) for speech-to-text. |
|
||||||
|
| **Training** | FAISS + sentence-transformers for semantic search over tips; GPT for personalized recommendations. |
|
||||||
|
| **Short Answer Grading** | GPT-4o for reading/listening fill-in-the-blank answers. |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All endpoints (except `/api/healthcheck`) require a Bearer JWT token validated with `JWT_SECRET_KEY` (HS256). The `ielts-ui` sends this as `BACKEND_JWT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. encoachcms (Content Management)
|
||||||
|
|
||||||
|
**Tech:** Strapi v4.21, Node.js 18, MySQL (prod) / SQLite (dev), Google Cloud Storage for uploads
|
||||||
|
|
||||||
|
**Role:** Headless CMS for managing the marketing website content. Editors create/update bilingual (EN/AR) content via the Strapi admin panel at `/admin`.
|
||||||
|
|
||||||
|
### Content Types (all Single Types)
|
||||||
|
|
||||||
|
`home`, `about`, `services`, `contact`, `footer`, `nav-bar`, `history`, `price`, `terms-and-conditions`, `privacy-policy`, `country-managers-contact`
|
||||||
|
|
||||||
|
### How it Connects
|
||||||
|
|
||||||
|
- Exposes REST API at `STRAPI_URL/api/{content-type}/?populate=deep&locale={en|ar}`
|
||||||
|
- Authenticated via API token (`STRAPI_TOKEN`)
|
||||||
|
- **Only consumed by `encoach-landing-page`** -- neither `ielts-ui` nor `ielts-be` use it
|
||||||
|
|
||||||
|
### Plugins
|
||||||
|
|
||||||
|
- `strapi-plugin-populate-deep` -- deep relation population
|
||||||
|
- `strapi-plugin-import-export-entries` -- content import/export
|
||||||
|
- `strapi-plugin-publisher` -- scheduled publishing
|
||||||
|
- `@strapi/plugin-i18n` -- internationalization
|
||||||
|
- `@strapi/plugin-documentation` -- OpenAPI docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. encoach-landing-page (Marketing Website)
|
||||||
|
|
||||||
|
**Tech:** Next.js 13 (App Router), TypeScript, Tailwind CSS + DaisyUI, Stripe
|
||||||
|
|
||||||
|
**Role:** Public marketing website at `encoach.com`. Bilingual (EN at `/`, AR at `/ar/`). Fetches content from the CMS and links users into the main platform.
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
`/` (Home), `/about`, `/services`, `/price`, `/history`, `/contact`, `/terms`, `/privacy-policy`, `/contacts/[country]`, `/success` -- all mirrored under `/ar/` for Arabic.
|
||||||
|
|
||||||
|
### How it Connects
|
||||||
|
|
||||||
|
| Target | Connection |
|
||||||
|
|--------|------------|
|
||||||
|
| **encoachcms** | `getData()` fetches `STRAPI_URL/api/{page}/?populate=deep&locale={locale}` with Bearer token |
|
||||||
|
| **ielts-ui** | `GET /api/packages` for pricing, `POST /api/tickets` for contact form, `GET /api/users/agents` for sales agents |
|
||||||
|
| **Stripe** | Checkout flow; `/api/success` route validates session and POSTs to `WEBHOOK_URL` |
|
||||||
|
| **Platform links** | Buttons link to `platform.encoach.com/register`, `platform.encoach.com/official-exam` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow Diagrams
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Browser
|
||||||
|
participant UI as ielts-ui
|
||||||
|
participant Firebase as Firebase Auth
|
||||||
|
participant Mongo as MongoDB
|
||||||
|
|
||||||
|
Browser->>UI: POST /api/login (email, password)
|
||||||
|
UI->>Firebase: signInWithEmailAndPassword
|
||||||
|
Firebase-->>UI: Firebase UID + token
|
||||||
|
UI->>Mongo: Find user by Firebase UID
|
||||||
|
Mongo-->>UI: User document
|
||||||
|
UI->>UI: Save to iron-session cookie
|
||||||
|
UI-->>Browser: Set-Cookie (eCrop/ielts)
|
||||||
|
Browser->>UI: Subsequent requests with cookie
|
||||||
|
UI->>UI: withIronSessionSsr validates cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exam Grading Flow (Writing)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Student
|
||||||
|
participant UI as ielts-ui API Route
|
||||||
|
participant BE as ielts-be
|
||||||
|
participant GPT as OpenAI GPT-4o
|
||||||
|
participant GPTZero as GPTZero
|
||||||
|
participant Mongo as MongoDB
|
||||||
|
|
||||||
|
Student->>UI: Submit writing answer
|
||||||
|
UI->>BE: POST /api/exam/grade/writing/{task}
|
||||||
|
BE->>BE: Start background task
|
||||||
|
BE-->>UI: 200 OK (accepted)
|
||||||
|
UI-->>Student: "Grading in progress"
|
||||||
|
|
||||||
|
par Parallel Grading
|
||||||
|
BE->>GPT: Evaluate against IELTS rubric
|
||||||
|
GPT-->>BE: Scores + feedback
|
||||||
|
and AI Detection
|
||||||
|
BE->>GPTZero: Check for AI content
|
||||||
|
GPTZero-->>BE: AI probability
|
||||||
|
end
|
||||||
|
|
||||||
|
BE->>Mongo: Save evaluation result
|
||||||
|
Student->>UI: Poll for result
|
||||||
|
UI->>Mongo: Query evaluation
|
||||||
|
Mongo-->>UI: Grading result
|
||||||
|
UI-->>Student: Display scores + feedback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Landing Page Content Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Visitor
|
||||||
|
participant LP as encoach-landing-page
|
||||||
|
participant CMS as encoachcms (Strapi)
|
||||||
|
participant Platform as ielts-ui
|
||||||
|
|
||||||
|
Visitor->>LP: Visit encoach.com
|
||||||
|
LP->>CMS: GET /api/home/?populate=deep&locale=en
|
||||||
|
CMS-->>LP: Home content (JSON)
|
||||||
|
LP-->>Visitor: Rendered page
|
||||||
|
|
||||||
|
Visitor->>LP: View pricing
|
||||||
|
LP->>CMS: GET /api/price/?populate=deep&locale=en
|
||||||
|
LP->>Platform: GET /api/packages
|
||||||
|
Platform-->>LP: Package list
|
||||||
|
LP-->>Visitor: Pricing page with packages
|
||||||
|
|
||||||
|
Visitor->>LP: Submit contact form
|
||||||
|
LP->>Platform: POST /api/tickets
|
||||||
|
Platform-->>LP: Ticket created
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Infrastructure
|
||||||
|
|
||||||
|
| Resource | Used By | Details |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| **MongoDB** | ielts-ui, ielts-be | Same database (`MONGODB_URI` / `MONGODB_DB`). UI reads users, groups, stats directly. BE reads/writes evaluations, training. |
|
||||||
|
| **Firebase Auth** | ielts-ui, ielts-be | Shared Firebase project. UI handles login/signup; BE imports users. |
|
||||||
|
| **Firebase Storage** | ielts-be | Stores exam media (audio, images). |
|
||||||
|
| **Google Cloud Storage** | encoachcms | CMS file uploads (images, media). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Topology
|
||||||
|
|
||||||
|
| Service | Port | URL (Production) |
|
||||||
|
|---------|------|-------------------|
|
||||||
|
| ielts-ui | 3000 | `platform.encoach.com` |
|
||||||
|
| ielts-be | 8000 | Internal (proxied by ielts-ui) |
|
||||||
|
| encoachcms | 1337 | Internal (consumed by landing page) |
|
||||||
|
| encoach-landing-page | 3000 | `encoach.com` |
|
||||||
|
|
||||||
|
Both Next.js apps use `output: "standalone"` and have Dockerfiles. The BE is not directly exposed to the internet -- it sits behind ielts-ui's API route proxy layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Roles
|
||||||
|
|
||||||
|
| Role | Access |
|
||||||
|
|------|--------|
|
||||||
|
| **student** | Take exams, view stats, training, assignments |
|
||||||
|
| **teacher** | Manage classrooms, assignments, view student performance |
|
||||||
|
| **admin** | Full platform management, user management, entities |
|
||||||
|
| **corporate** | Corporate dashboard, bulk operations |
|
||||||
|
| **mastercorporate** | Multi-entity corporate management |
|
||||||
|
| **developer** | Developer dashboard, system tools |
|
||||||
|
| **agent** | Sales agent, visible on landing page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Environment Variables (Cross-Service)
|
||||||
|
|
||||||
|
| Variable | Service | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `MONGODB_URI` / `MONGODB_DB` | ielts-ui, ielts-be | Shared MongoDB |
|
||||||
|
| `BACKEND_URL` | ielts-ui | URL of ielts-be |
|
||||||
|
| `BACKEND_JWT` / `JWT_SECRET_KEY` | ielts-ui / ielts-be | Shared JWT secret for service-to-service auth |
|
||||||
|
| `FIREBASE_*` | ielts-ui, ielts-be | Shared Firebase project credentials |
|
||||||
|
| `STRAPI_URL` / `STRAPI_TOKEN` | encoach-landing-page | CMS connection |
|
||||||
|
| `STRIPE_KEY` / `STRIPE_SECRET` | ielts-ui, encoach-landing-page | Payment processing |
|
||||||
|
| `OPENAI_API_KEY` | ielts-be | AI grading and generation |
|
||||||
|
| `AWS_ACCESS_KEY_ID/SECRET` | ielts-be | AWS Polly TTS |
|
||||||
249
docs/DEVELOPER_FEEDBACK.md
Normal file
249
docs/DEVELOPER_FEEDBACK.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# EnCoach Odoo 19 -- Developer Feedback
|
||||||
|
|
||||||
|
**Date:** March 11, 2026
|
||||||
|
**Status:** ALL ITEMS RESOLVED (March 13, 2026)
|
||||||
|
**Reference:** ODOO_MIGRATION_SRS_v2.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Assessment
|
||||||
|
|
||||||
|
The delivery covers approximately **85% of the SRS v2 requirements**. All 15 custom modules are present, the core AI/ML services are well-implemented, data models match the specification, and 67-72 API endpoints are wired up with proper JWT authentication and security groups. The code quality is solid -- clean structure, proper error handling, and good separation of concerns.
|
||||||
|
|
||||||
|
The items below still need to be implemented to reach full SRS compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Payment Integration (Critical -- Not Implemented)
|
||||||
|
|
||||||
|
**SRS Reference:** Section 7
|
||||||
|
|
||||||
|
All three payment services are currently stubs that return `"not yet implemented"`. The SRS requires full integration with:
|
||||||
|
|
||||||
|
### 1.1 Stripe
|
||||||
|
|
||||||
|
**Files:** `encoach_subscription/services/stripe_service.py`
|
||||||
|
|
||||||
|
Required functionality:
|
||||||
|
- Install and use the `stripe` Python SDK
|
||||||
|
- `create_checkout_session()` -- Create a Stripe Checkout Session using `stripe.checkout.Session.create()` with:
|
||||||
|
- `line_items` from the selected `encoach.package`
|
||||||
|
- `success_url` and `cancel_url` from the request
|
||||||
|
- `client_reference_id` = user ID
|
||||||
|
- `customer_email` = user email
|
||||||
|
- Apply discount if `discount_code` is provided (validate against `encoach.discount`)
|
||||||
|
- Return `{ "sessionId": "cs_...", "url": "https://checkout.stripe.com/..." }`
|
||||||
|
- **Webhook endpoint** (`POST /api/stripe/webhook`):
|
||||||
|
- Verify signature using `stripe.Webhook.construct_event()` with `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- Handle `checkout.session.completed` event:
|
||||||
|
- Find user by `client_reference_id`
|
||||||
|
- Extend `subscription_expiration` by the package's `duration_days`
|
||||||
|
- Create `encoach.subscription.payment` record
|
||||||
|
- For corporate users, propagate subscription to entity members
|
||||||
|
|
||||||
|
### 1.2 PayPal
|
||||||
|
|
||||||
|
**Files:** `encoach_subscription/services/paypal_service.py`
|
||||||
|
|
||||||
|
Required functionality:
|
||||||
|
- Use PayPal REST API (`httpx` or `requests`)
|
||||||
|
- Obtain access token from `PAYPAL_ACCESS_TOKEN_URL` using `client_id` + `client_secret`
|
||||||
|
- `create_order()` -- Create PayPal order via `POST /v2/checkout/orders`:
|
||||||
|
- `intent: "CAPTURE"`
|
||||||
|
- `purchase_units` with amount from package (minus discount)
|
||||||
|
- Return `{ "orderId": "...", "approvalUrl": "https://paypal.com/..." }`
|
||||||
|
- `capture_order()` -- Capture approved order via `POST /v2/checkout/orders/{id}/capture`:
|
||||||
|
- On success, extend subscription and create payment record
|
||||||
|
- Return `{ "ok": true }`
|
||||||
|
|
||||||
|
### 1.3 Paymob
|
||||||
|
|
||||||
|
**Files:** `encoach_subscription/services/paymob_service.py`
|
||||||
|
|
||||||
|
Required functionality:
|
||||||
|
- Use Paymob API (`httpx` or `requests`)
|
||||||
|
- `create_intention()` -- Create payment intention:
|
||||||
|
- Authenticate with `PAYMOB_API_KEY`
|
||||||
|
- Create order, then payment key
|
||||||
|
- Return `{ "intentionId": "...", "clientSecret": "..." }`
|
||||||
|
- `verify_transaction()` -- Verify webhook callback:
|
||||||
|
- Validate HMAC using `PAYMOB_SECRET`
|
||||||
|
- On success, extend subscription and create payment record
|
||||||
|
- **Webhook endpoint** (`POST /api/paymob/webhook`):
|
||||||
|
- Verify transaction authenticity
|
||||||
|
- Update subscription on success
|
||||||
|
|
||||||
|
### 1.4 Subscription Extension Logic
|
||||||
|
|
||||||
|
After any successful payment (all providers), the system must:
|
||||||
|
1. Calculate new expiration: `max(current_expiration, now()) + duration_days`
|
||||||
|
2. Update `user.subscription_expiration`
|
||||||
|
3. Create `encoach.subscription.payment` record with provider, amount, transaction ID
|
||||||
|
4. For corporate users: propagate the new expiration to all entity members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Missing API Endpoints
|
||||||
|
|
||||||
|
**SRS Reference:** Section 4
|
||||||
|
|
||||||
|
The following endpoints are specified in the SRS but not present in the delivery:
|
||||||
|
|
||||||
|
### 2.1 Exam Variants (add to `encoach_api/controllers/generation.py` or `exams.py`)
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `POST /api/exam/writing/<task>/attachment` | POST | Academic writing Task 1 with image upload (multipart form with `file` + `difficulty`). Must send image to GPT-4o vision API for prompt generation. |
|
||||||
|
| `GET /api/exam/level/` | GET | Return a pre-built level exam (no AI generation) |
|
||||||
|
| `GET /api/exam/level/utas` | GET | Return a UTAS-format level exam |
|
||||||
|
| `POST /api/exam/level/import/` | POST | Import level exam from uploaded file |
|
||||||
|
| `POST /api/exam/level/custom/` | POST | Generate custom level exam from JSON specification |
|
||||||
|
| `POST /api/exam/reading/import` | POST | Import reading exam from Word/Excel file |
|
||||||
|
| `POST /api/exam/listening/import` | POST | Import listening exam from file |
|
||||||
|
|
||||||
|
### 2.2 Entity/Role/Permission Management (new controller files needed)
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `GET /api/entities` | GET | List entities (with pagination) |
|
||||||
|
| `POST /api/entities` | POST | Create entity |
|
||||||
|
| `PATCH /api/entities/<id>` | PATCH | Update entity |
|
||||||
|
| `DELETE /api/entities/<id>` | DELETE | Delete entity |
|
||||||
|
| `GET /api/roles` | GET | List roles (filter by entityID) |
|
||||||
|
| `POST /api/roles` | POST | Create role |
|
||||||
|
| `PATCH /api/roles/<id>` | PATCH | Update role |
|
||||||
|
| `DELETE /api/roles/<id>` | DELETE | Delete role |
|
||||||
|
| `GET /api/permissions` | GET | List permissions |
|
||||||
|
|
||||||
|
### 2.3 Other Missing Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `GET /api/discounts` | GET | List discount codes |
|
||||||
|
| `POST /api/discounts` | POST | Create discount code |
|
||||||
|
| `PATCH /api/discounts/<id>` | PATCH | Update discount code |
|
||||||
|
| `DELETE /api/discounts/<id>` | DELETE | Delete discount code |
|
||||||
|
| `GET /api/approval-workflows` | GET | List approval workflows |
|
||||||
|
| `POST /api/approval-workflows` | POST | Create approval workflow |
|
||||||
|
| `PATCH /api/approval-workflows/<id>` | PATCH | Update approval workflow |
|
||||||
|
| `DELETE /api/approval-workflows/<id>` | DELETE | Delete approval workflow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Evaluation Status Endpoint Path
|
||||||
|
|
||||||
|
**SRS Reference:** Section 4.2
|
||||||
|
|
||||||
|
**Current:** `GET /api/evaluate/status?sessionId=...&exerciseId=...` (query params)
|
||||||
|
**SRS specifies:** `GET /api/evaluate/<sessionId>/<exerciseId>` (path params)
|
||||||
|
|
||||||
|
The frontend SRS document (`ODOO_MIGRATION_FRONTEND_SRS.md`) references the path-param version. Please either:
|
||||||
|
- (A) Change the endpoint to use path params to match the SRS, OR
|
||||||
|
- (B) Confirm that query params are intentional so we can update the frontend SRS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Record-Level Security Rules (`ir.rule`)
|
||||||
|
|
||||||
|
**SRS Reference:** Section 5
|
||||||
|
|
||||||
|
The delivery has model-level ACL rules (`ir.model.access.csv`) for each module, which is good. However, record-level security rules (`ir.rule`) are also needed to ensure:
|
||||||
|
|
||||||
|
- **Students** can only read/write their own records (sessions, stats, evaluations, training, tickets)
|
||||||
|
- **Teachers** can see records for students in their groups/assignments
|
||||||
|
- **Corporate** users can see records within their entity
|
||||||
|
- **Admin/Developer** users have unrestricted access
|
||||||
|
|
||||||
|
Example rules needed (add to each module's `security/` directory):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- encoach_stats/security/rules.xml -->
|
||||||
|
<record id="rule_stat_student_own" model="ir.rule">
|
||||||
|
<field name="name">Students see own stats</field>
|
||||||
|
<field name="model_id" ref="model_encoach_stat"/>
|
||||||
|
<field name="groups" eval="[(4, ref('encoach_core.group_encoach_student'))]"/>
|
||||||
|
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
|
||||||
|
Models that need record rules:
|
||||||
|
- `encoach.session` -- students see own sessions only
|
||||||
|
- `encoach.stat` -- students see own stats only
|
||||||
|
- `encoach.evaluation` -- students see own evaluations only
|
||||||
|
- `encoach.training` -- students see own training only
|
||||||
|
- `encoach.ticket` -- students see own tickets, admins see all
|
||||||
|
- `encoach.assignment` -- students see assignments they're assigned to
|
||||||
|
- `encoach.exam` -- filtered by access field (public/private/entity)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Whisper Scaling
|
||||||
|
|
||||||
|
**SRS Reference:** Section 6.5
|
||||||
|
|
||||||
|
**Current:** Single lazy-loaded Whisper model instance in `whisper_service.py`
|
||||||
|
**SRS specifies:** 4 model instances with `ThreadPoolExecutor(max_workers=4)` for parallel transcription
|
||||||
|
|
||||||
|
Update `encoach_ai_media/services/whisper_service.py` to:
|
||||||
|
1. Load the configurable number of model instances (from `encoach.whisper_workers` system parameter, default 4)
|
||||||
|
2. Use `concurrent.futures.ThreadPoolExecutor` to process transcription requests in parallel
|
||||||
|
3. Round-robin or queue-based assignment of requests to model instances
|
||||||
|
|
||||||
|
This is important for production performance when multiple students submit speaking exams simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. FAISS Index Type
|
||||||
|
|
||||||
|
**SRS Reference:** Section 6.8
|
||||||
|
|
||||||
|
**Current:** `faiss.IndexFlatIP` (inner product)
|
||||||
|
**SRS specifies:** `faiss.IndexFlatL2` (L2/Euclidean distance)
|
||||||
|
|
||||||
|
The current implementation normalizes vectors and uses inner product, which effectively gives cosine similarity. This works correctly, but it's a deviation from the SRS. If the original `ielts-be` training data was built with L2 distance, the results may differ slightly. Please verify which index type the original system used and align accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Payment Webhook Endpoints
|
||||||
|
|
||||||
|
**SRS Reference:** Section 7
|
||||||
|
|
||||||
|
In addition to the payment service implementations (Item 1 above), the following webhook controller routes need to be added to `encoach_api/controllers/subscriptions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
|
||||||
|
def stripe_webhook(self, **kwargs):
|
||||||
|
"""Handle Stripe webhook events (checkout.session.completed, etc.)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
|
||||||
|
def paymob_webhook(self, **kwargs):
|
||||||
|
"""Handle Paymob transaction callbacks."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
These must be `auth='public'` (no JWT) since they're called by external services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Action Items
|
||||||
|
|
||||||
|
| # | Item | Priority | Status |
|
||||||
|
|---|------|----------|--------|
|
||||||
|
| 1 | Payment integration (Stripe/PayPal/Paymob) | **Critical** | RESOLVED |
|
||||||
|
| 2 | Missing API endpoints (~20 endpoints) | **High** | RESOLVED |
|
||||||
|
| 3 | Evaluation status endpoint path alignment | **Medium** | RESOLVED |
|
||||||
|
| 4 | `ir.rule` record-level security rules | **High** | RESOLVED |
|
||||||
|
| 5 | Whisper multi-instance scaling | **Medium** | RESOLVED |
|
||||||
|
| 6 | FAISS index type verification | **Low** | RESOLVED |
|
||||||
|
| 7 | Payment webhook endpoints | **Critical** | RESOLVED |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes Already Applied
|
||||||
|
|
||||||
|
The following small fixes have been applied directly to the codebase:
|
||||||
|
|
||||||
|
1. **`encoach_ai_media/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `openai-whisper`, `librosa`, `soundfile`, `httpx`
|
||||||
|
2. **`encoach_training/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `faiss-cpu`, `sentence-transformers`
|
||||||
|
3. **`requirements.txt`** -- Created at project root with all Python dependencies and versions from SRS Section 6.10
|
||||||
186
docs/ENCOACH_PRODUCT_DESCRIPTION.md
Normal file
186
docs/ENCOACH_PRODUCT_DESCRIPTION.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# EnCoach -- Product Description
|
||||||
|
|
||||||
|
**Prepared for:** UTAS University Management
|
||||||
|
**Date:** March 24, 2026
|
||||||
|
**Version:** 1.0
|
||||||
|
**Classification:** Client-Facing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
EnCoach is an AI-powered adaptive learning platform designed to deliver personalized education across multiple academic subjects. The platform automatically assesses each student's current knowledge level, generates a tailored learning plan, delivers the right content at the right time, and continuously adjusts as the student progresses. EnCoach serves two distinct audiences -- **UTAS-enrolled students** who follow university curriculum with AI-enhanced guidance, and **freelance learners** worldwide who access fully AI-driven self-paced programs. The platform launches with three subjects -- **IELTS/English, Mathematics, and Information Technology** -- and is designed to accommodate any additional subject without rebuilding the system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Product Vision
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Traditional learning platforms deliver the same material to every student regardless of their existing knowledge. Students waste time reviewing topics they already understand, while critical gaps in their foundation go unaddressed. Teachers lack visibility into individual student needs at scale, and institutions struggle to provide personalized learning outside the classroom.
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
|
||||||
|
EnCoach combines a proven Learning Management System with an intelligent adaptive engine that treats every student as an individual. The platform:
|
||||||
|
|
||||||
|
- **Discovers what each student knows** through an AI-driven diagnostic assessment
|
||||||
|
- **Builds a personalized roadmap** that prioritizes weak areas while respecting how topics build on each other
|
||||||
|
- **Delivers the right content** -- human-curated materials when available, AI-generated explanations when not
|
||||||
|
- **Adapts in real time** -- as students learn faster or struggle, the plan adjusts automatically
|
||||||
|
- **Provides an AI coaching companion** available on every page for hints, explanations, and study advice
|
||||||
|
|
||||||
|
### Strategic Value for UTAS
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Scalable personalization** | Every UTAS student receives an individualized learning experience without increasing teaching staff |
|
||||||
|
| **Multi-subject from day one** | English, Math, and IT share a single platform; new subjects can be added by defining a topic structure |
|
||||||
|
| **Revenue expansion** | Freelance students outside UTAS generate subscription revenue on the same platform |
|
||||||
|
| **Data-driven insights** | Real-time analytics show administrators exactly where students succeed or struggle, per topic, per class, per department |
|
||||||
|
| **Quality assurance** | AI-generated content is supplementary -- academic staff control the primary learning materials and approve AI outputs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Capabilities
|
||||||
|
|
||||||
|
### 3.1 Adaptive Learning Engine
|
||||||
|
|
||||||
|
The core innovation of EnCoach. When a student begins a new subject, the platform runs an intelligent diagnostic that adapts in real time -- asking harder questions when answers are correct and easier ones when they are not. Within minutes, the system maps the student's strengths and weaknesses across every topic in the subject.
|
||||||
|
|
||||||
|
From this assessment, the AI generates a **personalized learning plan**: a sequenced path through topics the student needs to study, ordered so that foundational concepts are learned before advanced ones. As the student works through the plan, short mastery quizzes confirm understanding before moving on. If a student struggles, the system provides additional resources and intensifies AI coaching. If they progress quickly, the plan accelerates.
|
||||||
|
|
||||||
|
**For UTAS students**, the learning plan aligns with the university's curriculum and semester schedule. Teachers can view and override individual plans. **For freelance students**, the AI generates the complete plan independently based on the student's goals and timeline.
|
||||||
|
|
||||||
|
### 3.2 Multi-Subject Support
|
||||||
|
|
||||||
|
EnCoach is not a single-purpose English testing tool -- it is a universal education platform. Every subject is organized into the same clear hierarchy:
|
||||||
|
|
||||||
|
- **Subject** (e.g., Mathematics)
|
||||||
|
- **Domain** (e.g., Algebra)
|
||||||
|
- **Topic** (e.g., Linear Equations)
|
||||||
|
|
||||||
|
This structure supports any academic discipline. The platform launches with:
|
||||||
|
|
||||||
|
| Subject | Coverage |
|
||||||
|
|---------|----------|
|
||||||
|
| **IELTS / English** | Reading, Listening, Writing, Speaking, Grammar, Vocabulary, Pronunciation |
|
||||||
|
| **Mathematics** | Arithmetic, Algebra, Geometry, Statistics & Probability, Calculus |
|
||||||
|
| **Information Technology** | Computer Fundamentals, Networking, Databases, Programming, Cybersecurity |
|
||||||
|
|
||||||
|
Adding a new subject (e.g., Arabic, Physics, Business) requires only defining its topic structure -- the adaptive engine, exam system, grading, and analytics apply automatically.
|
||||||
|
|
||||||
|
### 3.3 Learning Management System
|
||||||
|
|
||||||
|
A full-featured LMS for structured academic delivery:
|
||||||
|
|
||||||
|
- **Course management** -- Create courses with modules and lessons, assign instructors, set enrollment limits
|
||||||
|
- **Student enrollment** -- UTAS students enrolled automatically via SIS sync; freelance students self-enroll
|
||||||
|
- **Batches** -- Group students for cohort-based learning and shared schedules
|
||||||
|
- **Timetable** -- Schedule and display class sessions for students, teachers, and administrators
|
||||||
|
- **Attendance** -- Teachers record attendance; students view their own records; administrators monitor patterns
|
||||||
|
- **Gradebook** -- Centralized grade management with AI-generated narrative reports for academic reviews
|
||||||
|
|
||||||
|
### 3.4 Exam Engine
|
||||||
|
|
||||||
|
A powerful assessment system that goes far beyond multiple-choice:
|
||||||
|
|
||||||
|
- **AI-generated exams** -- The platform creates new exam content on demand for any subject and difficulty level. English exams include reading passages, listening scripts, writing tasks, and speaking prompts. Math exams include numerical problems, word problems, and multi-step worked problems. IT exams include code completion, scenario-based questions, and true/false items.
|
||||||
|
- **AI-powered grading** -- Writing and speaking responses are graded automatically against detailed rubrics. Math answers are checked with numerical tolerance. IT code answers are evaluated for correctness and approach. All AI grades can be reviewed and approved by teachers through an approval workflow.
|
||||||
|
- **AI writing detection** -- Every written submission is scanned for AI-generated content, providing per-sentence confidence scores.
|
||||||
|
- **Multiple delivery modes** -- Practice exams, timed official exams, scheduled assignments, diagnostic assessments, and mastery quizzes.
|
||||||
|
|
||||||
|
### 3.5 AI Assistant
|
||||||
|
|
||||||
|
An intelligent companion embedded throughout the platform:
|
||||||
|
|
||||||
|
| What It Does | Where |
|
||||||
|
|-------------|-------|
|
||||||
|
| **Answers questions** in natural language about any topic | Available on every page via a chat drawer |
|
||||||
|
| **Gives hints** during practice exercises without revealing the answer | Practice and study pages |
|
||||||
|
| **Explains grades** so students understand what to improve | After exam results |
|
||||||
|
| **Suggests what to study next** based on progress and weaknesses | Student dashboard |
|
||||||
|
| **Generates study tips** personalized to the student's current topic | Topic learning pages |
|
||||||
|
| **Helps teachers create content** -- course outlines, assignments, rubrics | Teacher course builder |
|
||||||
|
| **Produces report narratives** for academic reviews | Admin reports |
|
||||||
|
| **Flags at-risk students** based on attendance, grades, and engagement patterns | Teacher and admin dashboards |
|
||||||
|
|
||||||
|
### 3.6 Administration and Reporting
|
||||||
|
|
||||||
|
Comprehensive tools for managing the platform at every level:
|
||||||
|
|
||||||
|
- **User management** -- Create, search, filter, and export student, teacher, and corporate accounts
|
||||||
|
- **Multi-tenant organizations** -- Each institution or company operates in its own space with its own roles, permissions, and branding
|
||||||
|
- **Classroom management** -- Create student groups, transfer students between classes
|
||||||
|
- **Assignment scheduling** -- Schedule exams with start/end dates, assign to classrooms or individual students, track completion
|
||||||
|
- **Permission system** -- Granular control over who can view, create, edit, or delete content, organized by subject
|
||||||
|
- **Analytics dashboards** -- Real-time metrics on student performance, subject completion rates, AI usage, content gaps, and resource utilization
|
||||||
|
- **Support ticketing** -- Built-in helpdesk for students and staff to report issues
|
||||||
|
- **Payment management** -- Institutional licensing for UTAS, individual subscriptions for freelance students, with support for multiple payment providers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. User Experience
|
||||||
|
|
||||||
|
### The Student Experience
|
||||||
|
|
||||||
|
A student logs in and sees their personalized dashboard: current subjects, learning plan progress, upcoming assignments, and a study streak counter. They select a subject and see their mastery profile -- a visual map showing which topics they have mastered, which they are working on, and which are locked until prerequisites are completed. Clicking a topic opens the learning page with curated materials (PDFs, videos, articles) and AI-generated explanations. After studying, a short mastery quiz confirms understanding. Throughout the experience, the AI coach is always available for hints, explanations, and encouragement. The student can also attend scheduled classes, view their timetable, check grades, and track attendance -- all from a single, unified interface.
|
||||||
|
|
||||||
|
### The Teacher Experience
|
||||||
|
|
||||||
|
A teacher logs in to see their class dashboard with insights at a glance: which students are progressing, which are at risk, and where content gaps exist. They can create courses using the AI course builder that generates outlines from a topic description. When grading, the AI Grading Assistant suggests marks and provides feedback drafts that the teacher can accept, modify, or override. Teachers can view any student's learning plan, adjust it if needed, and monitor mastery across their entire class through a heatmap visualization. Attendance recording and timetable management are integrated directly into their workflow.
|
||||||
|
|
||||||
|
### The Administrator Experience
|
||||||
|
|
||||||
|
An administrator has full visibility across the entire platform. The LMS dashboard shows enrollment numbers, completion rates, and subject-level performance across all courses. The platform dashboard provides user counts, entity statistics, and system health. Administrators manage organizations, roles, and permissions; they create exam structures, configure approval workflows, and oversee the content library. AI-powered reports generate narrative summaries for academic reviews, and the batch optimizer suggests class compositions for balanced learning outcomes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How AI Powers the Platform
|
||||||
|
|
||||||
|
EnCoach integrates six specialized AI capabilities, each designed for a specific educational purpose:
|
||||||
|
|
||||||
|
| Capability | What It Does for Users |
|
||||||
|
|-----------|----------------------|
|
||||||
|
| **Intelligent Conversation** | Powers the AI Assistant, the study coach, grade explanations, writing help, and content generation. Understands context -- it knows what page you are on, what topic you are studying, and what your recent performance looks like. |
|
||||||
|
| **Speech Recognition** | Converts student voice recordings into text for speaking exam grading. Supports multiple accents and speaking styles. |
|
||||||
|
| **Text-to-Speech** | Generates natural-sounding audio for English listening exams. Supports 11 distinct voices with regional accents for authentic test preparation. |
|
||||||
|
| **AI Video Avatars** | Creates video-based speaking prompts with realistic virtual presenters, making speaking practice more engaging and exam-like. |
|
||||||
|
| **Content Authenticity** | Scans written submissions to detect AI-generated text, ensuring academic integrity with per-sentence confidence analysis. |
|
||||||
|
| **Smart Recommendations** | Finds the most relevant training tips, study materials, and content recommendations based on semantic understanding of each student's needs -- not just keyword matching. |
|
||||||
|
|
||||||
|
All AI-generated content is supplementary. Academic staff control primary learning materials, and AI outputs can be routed through approval workflows before students see them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Integration with UTAS
|
||||||
|
|
||||||
|
| Integration Area | How It Works |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| **Student Information System (SIS)** | Student enrollment data syncs automatically from UTAS SIS. New students appear in EnCoach without manual data entry. Student records stay synchronized throughout the semester. |
|
||||||
|
| **Curriculum Alignment** | Learning plans for UTAS students follow the university's curriculum. The topic taxonomy maps to UTAS course structures, ensuring adaptive learning enhances rather than replaces the academic program. |
|
||||||
|
| **Institutional Licensing** | UTAS operates under an institutional license covering all enrolled students and staff. No individual subscriptions needed for university members. |
|
||||||
|
| **Teacher and Admin Oversight** | UTAS teachers see their students' AI-generated learning plans and can adjust them. Administrators have full visibility into performance analytics across all departments and subjects. |
|
||||||
|
| **Branding** | The platform can display UTAS branding (logo, colors) for an institution-native experience. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Platform at a Glance
|
||||||
|
|
||||||
|
| Dimension | Details |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Subjects** | IELTS/English, Mathematics, Information Technology (extensible to any subject) |
|
||||||
|
| **User Roles** | Student, Teacher, Admin, Corporate, Master Corporate, Agent, Developer |
|
||||||
|
| **Student Modes** | UTAS Institutional (SIS-synced, curriculum-aligned) and Freelance (self-registered, AI-guided) |
|
||||||
|
| **AI Capabilities** | Adaptive diagnostics, personalized learning plans, content generation, automated grading, real-time coaching, writing integrity detection |
|
||||||
|
| **LMS Features** | Courses, batches, timetable, attendance, gradebook, reporting |
|
||||||
|
| **Exam Types** | Practice, official, assignment-based, diagnostic, mastery quiz |
|
||||||
|
| **Content Model** | Hybrid -- human-uploaded resources prioritized, AI-generated content fills gaps |
|
||||||
|
| **Payment Options** | Institutional license, individual subscription, bundled packages |
|
||||||
|
| **Payment Providers** | Stripe, PayPal, Paymob |
|
||||||
|
| **Security** | Server-side authorization, role-based access control, encrypted authentication, data isolation between organizations |
|
||||||
|
| **Deployment** | Cloud-hosted, containerized architecture, automated scaling |
|
||||||
|
| **Accessibility** | Responsive design (desktop and mobile), accessible form controls, screen reader support |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document describes the EnCoach platform as specified in the Unified Platform SRS (v1.0, March 2026). For detailed technical specifications, refer to ENCOACH_UNIFIED_SRS.md.*
|
||||||
1287
docs/ENCOACH_UNIFIED_SRS.md
Normal file
1287
docs/ENCOACH_UNIFIED_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
1410
docs/MATH_IT_ADAPTIVE_LEARNING_SRS.md
Normal file
1410
docs/MATH_IT_ADAPTIVE_LEARNING_SRS.md
Normal file
File diff suppressed because it is too large
Load Diff
747
docs/ODOO_BACKEND_SRS_v3.md
Normal file
747
docs/ODOO_BACKEND_SRS_v3.md
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
# EnCoach Odoo 19 Backend -- Developer SRS v3
|
||||||
|
|
||||||
|
**Document Version:** 3.0
|
||||||
|
**Date:** March 11, 2026
|
||||||
|
**Status:** Active -- Ready for Development
|
||||||
|
**Supersedes:** `ODOO_MIGRATION_SRS_v2.md` (v2.0)
|
||||||
|
**Master Reference:** `ENCOACH_UNIFIED_SRS.md` (v1.0)
|
||||||
|
**Author:** EnCoach Architecture Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is the **backend implementation specification** for the Odoo 19 developer. It defines every API endpoint, data model, and module the frontend expects. The frontend is **fully built and wired** -- it makes real HTTP calls to these endpoints via TanStack Query. Your job is to ensure every endpoint listed here exists, returns the correct response shape, and connects to the correct Odoo models.
|
||||||
|
|
||||||
|
**Source of truth for response shapes:** `encoach_frontend_new/src/types/*.ts` (14 files)
|
||||||
|
**Source of truth for endpoint paths:** `encoach_frontend_new/src/services/*.ts` (21 files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [What Already Exists](#1-what-already-exists)
|
||||||
|
2. [What Needs to Be Built or Modified](#2-what-needs-to-be-built-or-modified)
|
||||||
|
3. [New Odoo Modules](#3-new-odoo-modules)
|
||||||
|
4. [Existing Module Modifications](#4-existing-module-modifications)
|
||||||
|
5. [Complete API Contract](#5-complete-api-contract)
|
||||||
|
6. [Data Model Specification](#6-data-model-specification)
|
||||||
|
7. [AI Service Integration](#7-ai-service-integration)
|
||||||
|
8. [Response Format Standards](#8-response-format-standards)
|
||||||
|
9. [Non-Functional Requirements](#9-non-functional-requirements)
|
||||||
|
10. [Implementation Priority](#10-implementation-priority)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Already Exists
|
||||||
|
|
||||||
|
### 1.1 Existing Odoo Modules (15)
|
||||||
|
|
||||||
|
These modules are built and deployed in `ielts-be-v0/`:
|
||||||
|
|
||||||
|
| # | Module | Controller File | Endpoints |
|
||||||
|
|---|--------|----------------|-----------|
|
||||||
|
| 1 | `encoach_core` | -- | Users, entities, roles, permissions, codes, invites |
|
||||||
|
| 2 | `encoach_ai` | -- | OpenAI service wrapper, constants, blacklist |
|
||||||
|
| 3 | `encoach_ai_media` | `media.py` | TTS (Polly), STT (Whisper), ELAI avatars |
|
||||||
|
| 4 | `encoach_ai_generation` | `generation.py` | Exam content generation per module |
|
||||||
|
| 5 | `encoach_ai_grading` | `grading.py` | Writing/speaking AI grading |
|
||||||
|
| 6 | `encoach_exam` | `exams.py` | Exam CRUD, approval workflows |
|
||||||
|
| 7 | `encoach_assignment` | `assignments.py` | Assignment management |
|
||||||
|
| 8 | `encoach_evaluation` | `evaluations.py` | Evaluation results and AI jobs |
|
||||||
|
| 9 | `encoach_stats` | `sessions.py`, `stats.py` | Exam sessions and statistics |
|
||||||
|
| 10 | `encoach_training` | `training.py` | Training content, tips (FAISS), walkthroughs |
|
||||||
|
| 11 | `encoach_classroom` | `classrooms.py` | Classroom groups |
|
||||||
|
| 12 | `encoach_subscription` | `subscriptions.py`, `discounts.py` | Packages, payments, discounts |
|
||||||
|
| 13 | `encoach_registration` | `registration.py` | User registration, batch import |
|
||||||
|
| 14 | `encoach_ticket` | `tickets.py` | Support tickets |
|
||||||
|
| 15 | `encoach_api` | `auth.py`, `users.py`, `entities.py`, `storage.py`, `approvals.py` | REST API controllers (81+ endpoints) |
|
||||||
|
|
||||||
|
### 1.2 Existing Endpoint Count by Group
|
||||||
|
|
||||||
|
| Group | Estimated Count | Status |
|
||||||
|
|-------|----------------|--------|
|
||||||
|
| Auth (`/api/login`, `/api/logout`, etc.) | 4 | Built |
|
||||||
|
| Users (`/api/user`, `/api/users/*`) | 6 | Built |
|
||||||
|
| Entities & Roles (`/api/entities`, `/api/roles`, `/api/permissions`) | 7 | Built |
|
||||||
|
| Exams (`/api/exam/*`) | 7 | Built |
|
||||||
|
| AI Generation (`/api/exam/*/generate`) | 12 | Built |
|
||||||
|
| Media (`/api/exam/*/media`, `/api/transcribe`) | 6 | Built |
|
||||||
|
| Evaluations (`/api/evaluate/*`, `/api/grading/*`) | 6 | Built |
|
||||||
|
| Classrooms (`/api/groups`) | 3 | Built |
|
||||||
|
| Assignments (`/api/assignments`) | 8 | Built |
|
||||||
|
| Sessions & Stats (`/api/sessions`, `/api/stats`, `/api/statistical`) | 5 | Built |
|
||||||
|
| Training (`/api/training/*`) | 4 | Built |
|
||||||
|
| Subscriptions (`/api/packages`, `/api/stripe`, etc.) | 7 | Built |
|
||||||
|
| Registration (`/api/register`, `/api/code/*`) | 4 | Built |
|
||||||
|
| Tickets (`/api/tickets`) | 3 | Built |
|
||||||
|
| Storage (`/api/storage`) | 2 | Built |
|
||||||
|
| Approvals (`/api/approval-workflows`) | 3 | Built |
|
||||||
|
| **TOTAL EXISTING** | **~87** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. What Needs to Be Built or Modified
|
||||||
|
|
||||||
|
### 2.1 New Endpoints Required (Frontend Expects These)
|
||||||
|
|
||||||
|
The frontend calls the following endpoints that **do not exist yet**. You must create them.
|
||||||
|
|
||||||
|
| Group | Count | Priority |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Taxonomy (subjects, domains, topics) | 14 | P0 |
|
||||||
|
| Adaptive Learning (diagnostic, proficiency, learning plans) | 16 | P0 |
|
||||||
|
| Resources (upload, manage, review) | 7 | P1 |
|
||||||
|
| AI Coaching (chat, suggest, explain, tips) | 6 | P1 |
|
||||||
|
| AI Utilities (search, insights, alerts, reports) | 5 | P2 |
|
||||||
|
| Analytics (student, class, subject, content gaps) | 4 | P2 |
|
||||||
|
| LMS Bridge (courses, batches, timetable, attendance, grades) | 13 | P1 |
|
||||||
|
| **TOTAL NEW** | **~65** | |
|
||||||
|
|
||||||
|
### 2.2 Existing Modules Requiring Modification
|
||||||
|
|
||||||
|
| Module | What to Change |
|
||||||
|
|--------|---------------|
|
||||||
|
| `encoach_exam` | Add `subject_id` (Many2one to `encoach.subject`) and `topic_ids` (Many2many to `encoach.topic`) fields to `encoach.exam` model |
|
||||||
|
| `encoach_stats` | Add `topic_id` and `subject_id` fields to `encoach.stat` model |
|
||||||
|
| `encoach_training` | Add `subject_id` field to `encoach.training.tip` for per-subject FAISS indices |
|
||||||
|
| `encoach_ai_generation` | Extend prompts to support Math (include LaTeX/KaTeX formatting) and IT (code blocks, syntax) question types |
|
||||||
|
| `encoach_ai_grading` | Add grading logic for numerical-tolerance (Math) and keyword/pattern (IT) answers |
|
||||||
|
| `encoach_subscription` | Add `subjects` field to `encoach.package` for subject-scoped subscriptions |
|
||||||
|
| `encoach_api` | Add new controller files for all new endpoint groups |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. New Odoo Modules
|
||||||
|
|
||||||
|
Create these 8 new modules:
|
||||||
|
|
||||||
|
### 3.1 `encoach_taxonomy`
|
||||||
|
|
||||||
|
**Purpose:** Subject/domain/topic/learning-objective hierarchy.
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
|
||||||
|
| Model | Key Fields |
|
||||||
|
|-------|-----------|
|
||||||
|
| `encoach.subject` | `name` (Char), `code` (Char unique), `is_active` (Boolean), `diagnostic_config` (Text/JSON), `mastery_threshold` (Float default=80), `grading_scale` (Selection: percentage/band/letter), `grading_scale_config` (Text/JSON) |
|
||||||
|
| `encoach.domain` | `name` (Char), `code` (Char), `subject_id` (M2O encoach.subject), `sequence` (Integer) |
|
||||||
|
| `encoach.topic` | `name` (Char), `code` (Char), `domain_id` (M2O encoach.domain), `estimated_hours` (Float), `difficulty_level` (Selection: easy/medium/hard), `prerequisite_ids` (M2M self-ref), `question_type_weights` (Text/JSON) |
|
||||||
|
| `encoach.learning.objective` | `name` (Char), `topic_id` (M2O encoach.topic), `bloom_level` (Selection: remember/understand/apply/analyze/evaluate/create), `sequence` (Integer) |
|
||||||
|
|
||||||
|
**Computed fields on `encoach.subject`:** `domain_count`, `topic_count`
|
||||||
|
**Computed fields on `encoach.domain`:** `topic_count`
|
||||||
|
**Computed fields on `encoach.topic`:** `objective_count`, `prerequisite_names` (list of names)
|
||||||
|
|
||||||
|
### 3.2 `encoach_resources`
|
||||||
|
|
||||||
|
**Purpose:** Human-uploaded learning materials tagged to topics.
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
|
||||||
|
| Model | Key Fields |
|
||||||
|
|-------|-----------|
|
||||||
|
| `encoach.resource` | `name` (Char), `resource_type` (Selection: pdf/video/link/document/interactive), `url` (Char), `file` (Binary), `file_name` (Char), `topic_ids` (M2M encoach.topic), `author_id` (M2O res.users), `is_published` (Boolean), `review_status` (Selection: pending/approved/rejected) |
|
||||||
|
| `encoach.resource.completion` | `student_id` (M2O res.users), `resource_id` (M2O encoach.resource), `viewed` (Boolean), `time_spent_minutes` (Integer), `rating` (Integer 1-5) |
|
||||||
|
|
||||||
|
### 3.3 `encoach_adaptive`
|
||||||
|
|
||||||
|
**Purpose:** Core adaptive learning engine -- proficiency tracking, learning plans, content cache.
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
|
||||||
|
| Model | Key Fields |
|
||||||
|
|-------|-----------|
|
||||||
|
| `encoach.proficiency` | `student_id` (M2O res.users), `topic_id` (M2O encoach.topic), `mastery` (Float 0-100), `mastery_level` (Selection: not_started/beginner/developing/proficient/mastered -- computed from mastery), `last_assessed` (Datetime), `time_spent_minutes` (Integer) |
|
||||||
|
| `encoach.learning.plan` | `student_id` (M2O res.users), `subject_id` (M2O encoach.subject), `status` (Selection: active/paused/completed), `ai_summary` (Text), `overall_progress` (Float -- computed), `target_completion` (Date) |
|
||||||
|
| `encoach.learning.plan.item` | `plan_id` (M2O encoach.learning.plan), `topic_id` (M2O encoach.topic), `sequence` (Integer), `status` (Selection: locked/available/in_progress/completed), `estimated_hours` (Float), `actual_hours` (Float), `mastery` (Float -- from proficiency) |
|
||||||
|
| `encoach.ai.content.cache` | `topic_id` (M2O encoach.topic), `content_type` (Char), `difficulty_level` (Char), `content` (Text/JSON), `model_used` (Char), `review_status` (Selection: auto/reviewed/rejected) |
|
||||||
|
|
||||||
|
**Business logic:**
|
||||||
|
- `mastery_level` is computed: 0-19 = not_started, 20-39 = beginner, 40-59 = developing, 60-79 = proficient, 80-100 = mastered
|
||||||
|
- `overall_progress` is computed from item statuses (completed_count / total_count * 100)
|
||||||
|
- When a plan item is completed, unlock the next item(s) based on prerequisite graph
|
||||||
|
|
||||||
|
### 3.4 `encoach_adaptive_api`
|
||||||
|
|
||||||
|
**Purpose:** REST controllers for the adaptive learning engine.
|
||||||
|
|
||||||
|
**Controller files to create:**
|
||||||
|
- `taxonomy.py` -- `/api/subjects`, `/api/domains`, `/api/topics`, `/api/subjects/{id}/taxonomy`
|
||||||
|
- `resources.py` -- `/api/resources`
|
||||||
|
- `diagnostic.py` -- `/api/diagnostic/start`, `/api/diagnostic/answer`, `/api/diagnostic/{id}/result`
|
||||||
|
- `proficiency.py` -- `/api/proficiency`, `/api/proficiency/summary`, `/api/proficiency/class`
|
||||||
|
- `learning_plan.py` -- `/api/learning-plan`, `/api/learning-plan/generate`, etc.
|
||||||
|
- `content.py` -- `/api/topics/{id}/content`, `/api/topics/{id}/practice`, `/api/topics/{id}/mastery-quiz`
|
||||||
|
|
||||||
|
### 3.5 `encoach_adaptive_ai`
|
||||||
|
|
||||||
|
**Purpose:** AI logic for diagnostics, plan generation, content generation, coaching.
|
||||||
|
|
||||||
|
**Key services:**
|
||||||
|
- Diagnostic question selection (adaptive difficulty algorithm)
|
||||||
|
- Learning plan generation (topological sort of topics by prerequisites, weighted by proficiency gaps)
|
||||||
|
- AI content generation for topics (call OpenAI GPT-4o with topic context)
|
||||||
|
- Practice question generation per topic
|
||||||
|
- Mastery quiz generation and grading
|
||||||
|
- Coaching: chat, hints, explanations, study suggestions, writing help, contextual tips
|
||||||
|
|
||||||
|
### 3.6 `encoach_lms_api`
|
||||||
|
|
||||||
|
**Purpose:** REST API bridge exposing OpenEduCat models to the frontend.
|
||||||
|
|
||||||
|
**Controller files to create:**
|
||||||
|
- `courses.py` -- `/api/courses`, `/api/courses/{id}`, `/api/courses/ai-generate`
|
||||||
|
- `batches.py` -- `/api/batches`, `/api/batches/{id}`
|
||||||
|
- `timetable.py` -- `/api/timetable`
|
||||||
|
- `attendance.py` -- `/api/attendance`
|
||||||
|
- `grades.py` -- `/api/grades`
|
||||||
|
|
||||||
|
**Note:** These controllers wrap the existing OpenEduCat Odoo models (`op.course`, `op.batch`, `op.session`, `op.attendance.sheet`, etc.) behind a clean REST API. Do NOT duplicate the models -- use the OpenEduCat models directly.
|
||||||
|
|
||||||
|
### 3.7 `encoach_sis`
|
||||||
|
|
||||||
|
**Purpose:** UTAS Student Information System integration.
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
- `encoach.sis.sync` -- sync job tracking (status, last_run, next_run, error_log)
|
||||||
|
- `encoach.sis.mapping` -- field mapping between SIS fields and EnCoach fields
|
||||||
|
|
||||||
|
### 3.8 `encoach_branding`
|
||||||
|
|
||||||
|
**Purpose:** Tenant whitelabeling.
|
||||||
|
|
||||||
|
**Models:**
|
||||||
|
- `encoach.branding` -- `entity_id` (M2O encoach.entity), `logo` (Binary), `primary_color` (Char), `secondary_color` (Char), `font_family` (Char)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Existing Module Modifications
|
||||||
|
|
||||||
|
### 4.1 `encoach_exam` -- Add Subject/Topic Fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In encoach_exam/models/exam.py, add:
|
||||||
|
subject_id = fields.Many2one('encoach.subject', string='Subject')
|
||||||
|
topic_ids = fields.Many2many('encoach.topic', string='Topics')
|
||||||
|
is_diagnostic = fields.Boolean(string='Is Diagnostic Exam', default=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 `encoach_stats` -- Add Subject/Topic Fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In encoach_stats/models/stat.py, add:
|
||||||
|
subject_id = fields.Many2one('encoach.subject', string='Subject')
|
||||||
|
topic_id = fields.Many2one('encoach.topic', string='Topic')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 `encoach_training` -- Add Subject Field
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In encoach_training/models/training_tip.py, add:
|
||||||
|
subject_id = fields.Many2one('encoach.subject', string='Subject')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `encoach_subscription` -- Add Subject Scoping
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In encoach_subscription/models/package.py, add:
|
||||||
|
subject_ids = fields.Many2many('encoach.subject', string='Included Subjects')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 `encoach_ai_generation` -- Multi-Subject Prompts
|
||||||
|
|
||||||
|
Extend the generation prompts to handle:
|
||||||
|
- **Math:** Include LaTeX formatting instructions in prompts, numerical-tolerance answer checking
|
||||||
|
- **IT:** Include code block formatting, syntax highlighting hints, code-completion question types
|
||||||
|
- **Generic:** Accept `subject_id` and `topic_id` parameters, adjust prompt templates per subject
|
||||||
|
|
||||||
|
### 4.6 `encoach_ai_grading` -- Multi-Subject Grading
|
||||||
|
|
||||||
|
Extend grading logic:
|
||||||
|
- **IELTS:** Band scoring (existing)
|
||||||
|
- **Math:** Numerical tolerance (accept answers within +/- epsilon), step-by-step partial credit
|
||||||
|
- **IT:** Keyword matching, regex pattern matching, AI-graded code explanations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complete API Contract
|
||||||
|
|
||||||
|
This is the definitive list of every endpoint the frontend calls. Each entry shows the HTTP method, path, request body (if POST/PATCH), and expected response shape. **The TypeScript type files in `encoach_frontend_new/src/types/` are the canonical response shapes.**
|
||||||
|
|
||||||
|
### 5.1 Authentication (Existing -- `auth.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/login` | `{ login: string, password: string }` | `{ token: string, user: User }` |
|
||||||
|
| `POST` | `/api/logout` | -- | `void` |
|
||||||
|
| `GET` | `/api/user` | -- | `User` |
|
||||||
|
| `POST` | `/api/reset/sendVerification` | `{ email: string }` | `{ success: boolean }` |
|
||||||
|
|
||||||
|
**User shape:** See `src/types/auth.ts` -- must include: `id`, `name`, `email`, `login`, `user_type` (one of: student/teacher/admin/corporate/mastercorporate/agent/developer), `is_verified`, `entities` (array of {id, name, role}), `classrooms`.
|
||||||
|
|
||||||
|
### 5.2 Users (Existing -- `users.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `GET` | `/api/users/list` | Query: `type`, `entity_id`, `page`, `size`, `search`, `sort`, `order` | `PaginatedResponse<User>` |
|
||||||
|
| `GET` | `/api/users/{id}` | -- | `User` |
|
||||||
|
| `PATCH` | `/api/users/update` | `{ id, ...fields }` | `User` |
|
||||||
|
| `POST` | `/api/users/create` | `Partial<User>` | `User` |
|
||||||
|
| `POST` | `/api/batch_users` | `{ users: Partial<User>[] }` | `{ success: boolean }` |
|
||||||
|
|
||||||
|
**PaginatedResponse shape:** `{ items: T[], total: number, page: number, size: number, pages: number }`
|
||||||
|
|
||||||
|
### 5.3 Entities (Existing -- `entities.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Response |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `GET` | `/api/entities` | `PaginatedResponse<Entity>` |
|
||||||
|
| `GET` | `/api/entities/{id}` | `Entity` |
|
||||||
|
| `POST` | `/api/entities` | `Entity` |
|
||||||
|
| `PATCH` | `/api/entities/{id}` | `Entity` |
|
||||||
|
| `DELETE` | `/api/entities/{id}` | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/entities/{id}/roles` | `EntityRole[]` |
|
||||||
|
| `POST` | `/api/entities/{id}/roles` | `EntityRole` |
|
||||||
|
| `PATCH` | `/api/roles/{id}/permissions` | `EntityRole` |
|
||||||
|
| `GET` | `/api/permissions?entity_id=` | `string[]` (list of permission keys for current user) |
|
||||||
|
|
||||||
|
### 5.4 Exams (Existing -- `exams.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Response |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `GET` | `/api/exam/{module}` | `PaginatedResponse<Exam>` |
|
||||||
|
| `GET` | `/api/exam/{module}/{id}` | `Exam` |
|
||||||
|
| `POST` | `/api/exam` | `Exam` |
|
||||||
|
| `PATCH` | `/api/exam/{id}` | `Exam` |
|
||||||
|
| `DELETE` | `/api/exam/{id}` | `{ success: boolean }` |
|
||||||
|
| `PATCH` | `/api/exam/{id}/access` | `Exam` |
|
||||||
|
| `GET` | `/api/rubrics` | `PaginatedResponse<Rubric>` |
|
||||||
|
| `POST` | `/api/rubrics` | `Rubric` |
|
||||||
|
| `GET` | `/api/rubric-groups` | `PaginatedResponse<RubricGroup>` |
|
||||||
|
| `GET` | `/api/exam-structures` | `PaginatedResponse<ExamStructure>` |
|
||||||
|
| `POST` | `/api/exam-structures` | `ExamStructure` |
|
||||||
|
| `DELETE` | `/api/exam-structures/{id}` | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/exam/avatars` | `[{ id, name, thumbnail, voice }]` |
|
||||||
|
|
||||||
|
**Note:** `Exam` response must now include optional `subject_id` and `topic_ids` fields.
|
||||||
|
|
||||||
|
### 5.5 Assignments (Existing -- `assignments.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Response |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `GET` | `/api/assignments` | `PaginatedResponse<Assignment>` |
|
||||||
|
| `GET` | `/api/assignments/{id}` | `Assignment` |
|
||||||
|
| `POST` | `/api/assignments` | `Assignment` |
|
||||||
|
| `PATCH` | `/api/assignments/{id}` | `Assignment` |
|
||||||
|
| `DELETE` | `/api/assignments/{id}` | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/assignments/{id}/archive` | `Assignment` |
|
||||||
|
| `POST` | `/api/assignments/{id}/start` | `Assignment` |
|
||||||
|
|
||||||
|
### 5.6 Classrooms (Existing -- `classrooms.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Response |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `GET` | `/api/groups` | `PaginatedResponse<Classroom>` |
|
||||||
|
| `GET` | `/api/groups/{id}` | `Classroom` |
|
||||||
|
| `POST` | `/api/groups` | `Classroom` |
|
||||||
|
| `DELETE` | `/api/groups/{id}` | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/groups/transfer` | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/groups/{id}/members` | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/groups/{id}/members/remove` | `{ success: boolean }` |
|
||||||
|
|
||||||
|
### 5.7 Stats (Existing -- `stats.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Response |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `GET` | `/api/sessions` | `ExamSession[]` |
|
||||||
|
| `GET` | `/api/stats` | `ExamStat[]` |
|
||||||
|
| `GET` | `/api/statistical` | `StatisticalData` |
|
||||||
|
| `GET` | `/api/stats/performance` | `unknown[]` |
|
||||||
|
|
||||||
|
### 5.8 Evaluations (Existing -- `evaluations.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/evaluate/writing` | `{ session_id, text, rubric_id? }` | `Evaluation` |
|
||||||
|
| `POST` | `/api/evaluate/speaking` | `{ session_id, audio_url, rubric_id? }` | `Evaluation` |
|
||||||
|
| `POST` | `/api/grading/multiple` | `{ session_id, answers: [{exercise_index, answer}] }` | `{ results: [{exercise_index, correct, score}] }` |
|
||||||
|
| `POST` | `/api/transcribe` | `FormData (audio file)` | `{ text: string }` |
|
||||||
|
|
||||||
|
### 5.9 Generation (Existing -- `generation.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/exam/{module}/generate` | `{ title, label?, entity_id?, subject_id?, topic_id?, difficulty?, count? }` | `{ exam_id, exercises: [] }` |
|
||||||
|
| `POST` | `/api/exam/{module}/generate/scratch` | Same as above | Same as above |
|
||||||
|
|
||||||
|
**Note:** Must now accept `subject_id` and `topic_id` to scope generation to specific subjects/topics.
|
||||||
|
|
||||||
|
### 5.10 Training, Subscriptions, Tickets, Storage, Approvals (Existing)
|
||||||
|
|
||||||
|
These follow the existing patterns. See `training.service.ts`, `subscriptions.service.ts`, `tickets.service.ts`, `storage.service.ts`, `approvals.service.ts` for exact paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.11 Taxonomy (NEW -- `taxonomy.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request/Params | Response |
|
||||||
|
|--------|------|---------------|----------|
|
||||||
|
| `GET` | `/api/subjects` | -- | `Subject[]` |
|
||||||
|
| `GET` | `/api/subjects/{id}` | -- | `Subject` |
|
||||||
|
| `POST` | `/api/subjects` | `Partial<Subject>` | `Subject` |
|
||||||
|
| `PATCH` | `/api/subjects/{id}` | `Partial<Subject>` | `Subject` |
|
||||||
|
| `DELETE` | `/api/subjects/{id}` | -- | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/subjects/{id}/taxonomy` | -- | `TaxonomyTree` (nested: subject + domains + topics + objectives) |
|
||||||
|
| `POST` | `/api/subjects/{id}/taxonomy/import` | `FormData (CSV/JSON)` | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/domains` | Query: `subject_id?` | `Domain[]` |
|
||||||
|
| `POST` | `/api/domains` | `Partial<Domain>` | `Domain` |
|
||||||
|
| `PATCH` | `/api/domains/{id}` | `Partial<Domain>` | `Domain` |
|
||||||
|
| `DELETE` | `/api/domains/{id}` | -- | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/domains/{id}/ai-suggest` | -- | `{ suggestions: Partial<Topic>[] }` |
|
||||||
|
| `GET` | `/api/topics` | Query: `domain_id?`, `subject_id?` | `Topic[]` |
|
||||||
|
| `POST` | `/api/topics` | `Partial<Topic>` | `Topic` |
|
||||||
|
| `PATCH` | `/api/topics/{id}` | `Partial<Topic>` | `Topic` |
|
||||||
|
| `DELETE` | `/api/topics/{id}` | -- | `{ success: boolean }` |
|
||||||
|
|
||||||
|
**TaxonomyTree shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject": { "id": 1, "name": "IELTS", "code": "IELTS", ... },
|
||||||
|
"domains": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "Writing", "code": "W", "sequence": 1,
|
||||||
|
"topics": [
|
||||||
|
{
|
||||||
|
"id": 1, "name": "Task 1", "difficulty_level": "medium",
|
||||||
|
"objectives": [
|
||||||
|
{ "id": 1, "name": "Describe trends", "bloom_level": "apply" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.12 Resources (NEW -- `resources.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request/Params | Response |
|
||||||
|
|--------|------|---------------|----------|
|
||||||
|
| `GET` | `/api/resources` | Query: `topic_id?`, `resource_type?`, `review_status?`, `page`, `size`, `search` | `PaginatedResponse<Resource>` |
|
||||||
|
| `POST` | `/api/resources` | `FormData` (file + metadata) | `Resource` |
|
||||||
|
| `PATCH` | `/api/resources/{id}` | `Partial<Resource>` | `Resource` |
|
||||||
|
| `DELETE` | `/api/resources/{id}` | -- | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/resources/{id}/complete` | -- | `ResourceCompletion` |
|
||||||
|
| `POST` | `/api/resources/{id}/rate` | `{ rating: number }` | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/resources/{id}/download` | -- | Binary file (stream) |
|
||||||
|
|
||||||
|
### 5.13 Diagnostic Assessment (NEW -- `adaptive.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/diagnostic/start` | `{ subject_id: number }` | `{ session: DiagnosticSession, first_question: DiagnosticQuestion }` |
|
||||||
|
| `POST` | `/api/diagnostic/answer` | `{ session_id, question_id, answer }` | `{ next_question?: DiagnosticQuestion, completed: boolean }` |
|
||||||
|
| `GET` | `/api/diagnostic/{id}/result` | -- | `DiagnosticResult` |
|
||||||
|
|
||||||
|
**DiagnosticQuestion shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "q_uuid",
|
||||||
|
"topic_id": 5,
|
||||||
|
"topic_name": "Line Graphs",
|
||||||
|
"domain_name": "Writing",
|
||||||
|
"difficulty": "medium",
|
||||||
|
"question_type": "multiple_choice",
|
||||||
|
"question_text": "Which of the following...",
|
||||||
|
"options": ["A", "B", "C", "D"],
|
||||||
|
"time_limit_seconds": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnostic algorithm:**
|
||||||
|
1. On `/start`: Create a diagnostic session. Pull topics from all domains. Start with `starting_difficulty` from subject's `diagnostic_config`.
|
||||||
|
2. On `/answer`: Grade the answer. If correct, increase difficulty and mastery estimate for that topic. If incorrect, decrease. Select the next question from a different domain/topic. Use Computer Adaptive Testing (CAT) principles.
|
||||||
|
3. Continue until `total_question_cap` reached or all domains have 2+ data points.
|
||||||
|
4. On completion: Write proficiency records for all assessed topics.
|
||||||
|
|
||||||
|
### 5.14 Proficiency (NEW -- `adaptive.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Params | Response |
|
||||||
|
|--------|------|--------|----------|
|
||||||
|
| `GET` | `/api/proficiency` | Query: `subject_id?` | `Proficiency[]` |
|
||||||
|
| `GET` | `/api/proficiency/summary` | -- | `ProficiencySummary[]` |
|
||||||
|
| `GET` | `/api/proficiency/class` | Query: `subject_id?` | Class-level aggregation |
|
||||||
|
|
||||||
|
**ProficiencySummary shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subject_id": 1,
|
||||||
|
"subject_name": "IELTS",
|
||||||
|
"overall_mastery": 65.5,
|
||||||
|
"domain_scores": [{ "domain_id": 1, "domain_name": "Writing", "mastery": 72 }],
|
||||||
|
"topics_mastered": 8,
|
||||||
|
"topics_total": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.15 Learning Plan (NEW -- `adaptive.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `GET` | `/api/learning-plan` | Query: `subject_id` | `LearningPlan` |
|
||||||
|
| `POST` | `/api/learning-plan/generate` | `{ subject_id, target_completion? }` | `LearningPlan` |
|
||||||
|
| `PATCH` | `/api/learning-plan/{id}` | `Partial<LearningPlan>` | `LearningPlan` |
|
||||||
|
| `POST` | `/api/learning-plan/{id}/pause` | -- | `LearningPlan` |
|
||||||
|
| `POST` | `/api/learning-plan/{id}/resume` | -- | `LearningPlan` |
|
||||||
|
|
||||||
|
**Plan generation algorithm:**
|
||||||
|
1. Get student's proficiency records for the subject.
|
||||||
|
2. Identify topics below mastery threshold.
|
||||||
|
3. Topologically sort topics by prerequisites.
|
||||||
|
4. Create plan items in sequence. First item = available, rest = locked.
|
||||||
|
5. Call GPT-4o to generate `ai_summary` (natural language description of the plan).
|
||||||
|
6. Set `estimated_hours` from topic definitions.
|
||||||
|
|
||||||
|
### 5.16 Content Delivery (NEW -- `adaptive.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `GET` | `/api/topics/{id}/content` | -- | `TopicContent` |
|
||||||
|
| `POST` | `/api/topics/{id}/generate-content` | -- | `TopicContent` |
|
||||||
|
| `POST` | `/api/topics/{id}/practice` | -- | `{ questions: [] }` |
|
||||||
|
| `POST` | `/api/topics/{id}/practice/grade` | `{ answers: [] }` | `{ results: [], mastery_update: number }` |
|
||||||
|
| `POST` | `/api/topics/{id}/mastery-quiz` | -- | `{ questions: [] }` |
|
||||||
|
| `POST` | `/api/topics/{id}/mastery-quiz/submit` | `{ answers: [] }` | `{ passed: boolean, score: number, mastery_update: number }` |
|
||||||
|
|
||||||
|
**TopicContent shape:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"topic_id": 5,
|
||||||
|
"topic_name": "Line Graphs",
|
||||||
|
"resources": [{ "id": 1, "name": "...", "resource_type": "pdf", "url": "..." }],
|
||||||
|
"ai_content": {
|
||||||
|
"explanation": "Line graphs show...",
|
||||||
|
"examples": ["Example 1...", "Example 2..."],
|
||||||
|
"key_points": ["Point 1", "Point 2"],
|
||||||
|
"model_used": "gpt-4o"
|
||||||
|
},
|
||||||
|
"has_content_gap": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`has_content_gap`** = true when no human-uploaded resources exist for this topic, and AI content was generated to fill the gap.
|
||||||
|
|
||||||
|
### 5.17 AI Coaching (NEW -- `coaching.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/coach/chat` | `{ message, context?: { page?, topic_id?, subject_id? }, history?: AiChatMessage[] }` | `{ message: string, suggestions?: string[] }` |
|
||||||
|
| `POST` | `/api/coach/hint` | `{ topic_id, question_id }` | `{ hint: string }` |
|
||||||
|
| `POST` | `/api/coach/explain` | `{ context, scores? }` | `{ explanation: string }` |
|
||||||
|
| `POST` | `/api/coach/suggest` | `{ subject_id? }` | `{ suggestions: string[], study_plan_tips: string[] }` |
|
||||||
|
| `POST` | `/api/coach/writing-help` | `{ text, task_type }` | `{ feedback, improved, grammar_notes: string[] }` |
|
||||||
|
| `GET` | `/api/coach/tip` | Query: `context` | `{ title, content, category }` |
|
||||||
|
|
||||||
|
All coaching endpoints use GPT-4o with appropriate system prompts.
|
||||||
|
|
||||||
|
### 5.18 AI Utilities (NEW -- `analytics.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `POST` | `/api/ai/search` | `{ query }` | `AiSearchResult[]` |
|
||||||
|
| `POST` | `/api/ai/insights` | `{ data: {} }` | `AiInsight[]` |
|
||||||
|
| `GET` | `/api/ai/alerts` | -- | `AiAlert[]` |
|
||||||
|
| `POST` | `/api/ai/report-narrative` | `{ report_type, data: {} }` | `{ narrative: string }` |
|
||||||
|
| `POST` | `/api/ai/batch-optimize` | `{ batch_id }` | `AiBatchOptimization[]` |
|
||||||
|
| `POST` | `/api/ai/grade-suggest` | `{ submission_id, text, rubric_id? }` | `AiGradingResult` |
|
||||||
|
|
||||||
|
### 5.19 Analytics (NEW -- `analytics.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Params | Response |
|
||||||
|
|--------|------|--------|----------|
|
||||||
|
| `GET` | `/api/analytics/student` | Query params | Dashboard data |
|
||||||
|
| `GET` | `/api/analytics/class` | Query params | Class analytics |
|
||||||
|
| `GET` | `/api/analytics/subject` | Query params | Subject analytics |
|
||||||
|
| `GET` | `/api/analytics/content-gaps` | Query: `subject_id?` | `{ gaps: [{ topic_id, topic_name, resource_count }] }` |
|
||||||
|
|
||||||
|
### 5.20 LMS Bridge (NEW -- `lms.service.ts`)
|
||||||
|
|
||||||
|
| Method | Path | Request | Response |
|
||||||
|
|--------|------|---------|----------|
|
||||||
|
| `GET` | `/api/courses` | Query: `page`, `size`, `search`, `status` | `PaginatedResponse<Course>` |
|
||||||
|
| `GET` | `/api/courses/{id}` | -- | `Course` |
|
||||||
|
| `POST` | `/api/courses` | `CourseCreateRequest` | `Course` |
|
||||||
|
| `PATCH` | `/api/courses/{id}` | `Partial<CourseCreateRequest>` | `Course` |
|
||||||
|
| `DELETE` | `/api/courses/{id}` | -- | `{ success: boolean }` |
|
||||||
|
| `POST` | `/api/courses/ai-generate` | `{ title, subject_id?, level? }` | `{ outline: {} }` |
|
||||||
|
| `GET` | `/api/batches` | Query: pagination | `PaginatedResponse<Batch>` |
|
||||||
|
| `GET` | `/api/batches/{id}` | -- | `Batch` |
|
||||||
|
| `POST` | `/api/batches` | `Partial<Batch>` | `Batch` |
|
||||||
|
| `PATCH` | `/api/batches/{id}` | `Partial<Batch>` | `Batch` |
|
||||||
|
| `DELETE` | `/api/batches/{id}` | -- | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/timetable` | Query: `course_id?`, `teacher_id?`, `batch_id?` | `TimetableSession[]` |
|
||||||
|
| `POST` | `/api/timetable` | `Partial<TimetableSession>` | `TimetableSession` |
|
||||||
|
| `GET` | `/api/attendance` | Query: `course_id?`, `student_id?`, `date?` | `AttendanceRecord[]` |
|
||||||
|
| `POST` | `/api/attendance` | `{ course_id, date, records: [{student_id, status}] }` | `{ success: boolean }` |
|
||||||
|
| `GET` | `/api/grades` | Query: `course_id?`, `student_id?` | `GradeRecord[]` |
|
||||||
|
|
||||||
|
**Course shape:** See `src/types/lms.ts` -- must include: `id`, `title`, `code`, `subject_id?`, `subject_name?`, `instructor_id`, `instructor_name`, `description`, `level`, `modules[]`, `enrolled`, `max_capacity`, `status`, `start_date`, `end_date`, `progress?`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data Model Specification
|
||||||
|
|
||||||
|
All data model details are in **Section 30 of `ENCOACH_UNIFIED_SRS.md`** (existing models: 30.1, new models: 30.2-30.4). Refer to that document for the complete field-level specification.
|
||||||
|
|
||||||
|
**Key relationships:**
|
||||||
|
```
|
||||||
|
Subject (1) → (N) Domain (1) → (N) Topic (1) → (N) LearningObjective
|
||||||
|
Topic (M) ↔ (M) Topic (prerequisites -- self-referencing M2M)
|
||||||
|
Topic (M) ↔ (M) Resource
|
||||||
|
Student (1) → (N) Proficiency (per topic)
|
||||||
|
Student (1) → (N) LearningPlan (per subject) → (N) LearningPlanItem (per topic)
|
||||||
|
Exam → Subject (optional M2O)
|
||||||
|
Exam → Topic[] (optional M2M)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. AI Service Integration
|
||||||
|
|
||||||
|
The following external AI services are already configured (API keys in `encoach_core/data/constants.xml` and server `.env`):
|
||||||
|
|
||||||
|
| Service | Purpose | Config Key |
|
||||||
|
|---------|---------|-----------|
|
||||||
|
| OpenAI GPT-4o | Content generation, grading, coaching, plan generation | `encoach.openai_api_key` |
|
||||||
|
| OpenAI GPT-3.5-turbo | Lightweight tasks (tips, search, suggestions) | Same key |
|
||||||
|
| OpenAI Whisper (local) | Speech-to-text | Local model |
|
||||||
|
| AWS Polly (Neural) | Text-to-speech for listening exams | `encoach.aws_access_key_id`, `encoach.aws_secret_access_key` |
|
||||||
|
| ELAI | AI avatar video generation | `encoach.elai_api_key` |
|
||||||
|
| GPTZero | AI writing detection | `encoach.gptzero_api_key` |
|
||||||
|
| FAISS + SentenceTransformers | Semantic similarity for training tips | Local model (`all-MiniLM-L6-v2`) |
|
||||||
|
|
||||||
|
**New AI tasks to implement:**
|
||||||
|
|
||||||
|
| Task | Model | Endpoint |
|
||||||
|
|------|-------|----------|
|
||||||
|
| Diagnostic question generation | GPT-4o | `/api/diagnostic/start`, `/answer` |
|
||||||
|
| Learning plan generation | GPT-4o | `/api/learning-plan/generate` |
|
||||||
|
| Topic content generation | GPT-4o | `/api/topics/{id}/generate-content` |
|
||||||
|
| Practice question generation | GPT-4o | `/api/topics/{id}/practice` |
|
||||||
|
| Mastery quiz generation | GPT-4o | `/api/topics/{id}/mastery-quiz` |
|
||||||
|
| AI coaching chat | GPT-4o | `/api/coach/chat` |
|
||||||
|
| Study suggestions | GPT-3.5-turbo | `/api/coach/suggest` |
|
||||||
|
| Writing feedback | GPT-4o | `/api/coach/writing-help` |
|
||||||
|
| Grade explanation | GPT-4o | `/api/coach/explain` |
|
||||||
|
| Contextual tips | GPT-3.5-turbo | `/api/coach/tip` |
|
||||||
|
| Semantic search | FAISS | `/api/ai/search` |
|
||||||
|
| Report narrative | GPT-4o | `/api/ai/report-narrative` |
|
||||||
|
| Data insights | GPT-4o | `/api/ai/insights` |
|
||||||
|
| Topic suggestion | GPT-4o | `/api/domains/{id}/ai-suggest` |
|
||||||
|
| Course outline generation | GPT-4o | `/api/courses/ai-generate` |
|
||||||
|
| Batch optimization | GPT-3.5-turbo | `/api/ai/batch-optimize` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Response Format Standards
|
||||||
|
|
||||||
|
### 8.1 Paginated Responses
|
||||||
|
|
||||||
|
All list endpoints that support pagination must return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [...],
|
||||||
|
"total": 150,
|
||||||
|
"page": 1,
|
||||||
|
"size": 20,
|
||||||
|
"pages": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Query parameters: `page` (1-based), `size` (default 20), `search` (text filter), `sort` (field name), `order` (asc/desc).
|
||||||
|
|
||||||
|
### 8.2 Error Responses
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Human-readable error message",
|
||||||
|
"code": "ERROR_CODE",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP status codes: 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), 500 (server error).
|
||||||
|
|
||||||
|
### 8.3 Success Responses
|
||||||
|
|
||||||
|
For write operations that don't return an entity:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Optional message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 JWT Token
|
||||||
|
|
||||||
|
- Token returned on login as `{ token: "...", user: {...} }`
|
||||||
|
- Frontend sends `Authorization: Bearer <token>` on every request
|
||||||
|
- 401 response = token expired, frontend redirects to login
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Non-Functional Requirements
|
||||||
|
|
||||||
|
| Requirement | Specification |
|
||||||
|
|-------------|--------------|
|
||||||
|
| API response time (CRUD) | < 2 seconds |
|
||||||
|
| Diagnostic question generation | < 3 seconds |
|
||||||
|
| AI grading (writing/speaking) | < 60 seconds |
|
||||||
|
| Learning plan generation | < 10 seconds |
|
||||||
|
| AI content generation | < 15 seconds |
|
||||||
|
| Concurrent students per subject | 200 |
|
||||||
|
| Topics per subject | 500 |
|
||||||
|
| Resources per subject | 1,000 |
|
||||||
|
| Pagination on all list endpoints | Required |
|
||||||
|
| Server-side search/filter | Required |
|
||||||
|
| Soft delete for resources | Required (preserve completion records) |
|
||||||
|
| Proficiency weighted averages | Required (never direct overwrite) |
|
||||||
|
| Math content | LaTeX/KaTeX formatting in question text |
|
||||||
|
| IT content | Code blocks with language hints in question text |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Implementation Priority
|
||||||
|
|
||||||
|
| Phase | Modules | Endpoints | Estimated Effort |
|
||||||
|
|-------|---------|-----------|-----------------|
|
||||||
|
| **P0** | `encoach_taxonomy`, modify `encoach_exam`/`encoach_stats`/`encoach_training` | Taxonomy CRUD (14), Subject/Topic fields on existing models | 1 week |
|
||||||
|
| **P1** | `encoach_adaptive`, `encoach_adaptive_api`, `encoach_adaptive_ai` | Diagnostic (3), Proficiency (3), Learning Plan (5), Content (6) | 2 weeks |
|
||||||
|
| **P1** | `encoach_resources` | Resource CRUD (7) | 3 days |
|
||||||
|
| **P1** | `encoach_lms_api` | LMS bridge (13) | 1 week |
|
||||||
|
| **P2** | `encoach_adaptive_ai` (coaching) | AI Coaching (6), AI Utilities (5) | 1 week |
|
||||||
|
| **P2** | Analytics controllers | Analytics (4) | 3 days |
|
||||||
|
| **P3** | `encoach_sis`, `encoach_branding` | SIS (4), Branding | 1 week |
|
||||||
|
| **P3** | Multi-subject prompts/grading | Modify generation & grading modules | 3 days |
|
||||||
|
| **TOTAL** | 8 new modules + 7 modified | ~65 new endpoints | ~7 weeks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: File Handoff Checklist
|
||||||
|
|
||||||
|
| File/Folder | Purpose | Location |
|
||||||
|
|-------------|---------|----------|
|
||||||
|
| This document | Backend SRS | `ODOO_BACKEND_SRS_v3.md` |
|
||||||
|
| Product SRS | Full platform specification | `ENCOACH_UNIFIED_SRS.md` |
|
||||||
|
| TypeScript types | Response shape contracts | `encoach_frontend_new/src/types/*.ts` |
|
||||||
|
| Service layer | Endpoint path contracts | `encoach_frontend_new/src/services/*.ts` |
|
||||||
|
| Existing modules | Already-built backend | `ielts-be-v0/` |
|
||||||
|
| Developer feedback | Review of existing work | `ielts-be-v0/DEVELOPER_FEEDBACK.md` |
|
||||||
|
| AI keys & config | Service credentials | `ielts-be-v0/encoach_core/data/constants.xml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document supersedes `ODOO_MIGRATION_SRS_v2.md`. The Odoo developer should treat this as the definitive backend specification. All endpoint paths, response shapes, and data models are aligned with the production frontend at `encoach_frontend_new/`.*
|
||||||
1292
docs/ODOO_MIGRATION_SRS_v2.md
Normal file
1292
docs/ODOO_MIGRATION_SRS_v2.md
Normal file
File diff suppressed because it is too large
Load Diff
727
docs/UTAS_MASTER_PLAN.md
Normal file
727
docs/UTAS_MASTER_PLAN.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# EnCoach -- UTAS University Deployment Master Plan
|
||||||
|
|
||||||
|
**Document Version:** 1.0
|
||||||
|
**Date:** March 16, 2026
|
||||||
|
**Classification:** Confidential -- EnCoach & UTAS Internal
|
||||||
|
**Prepared by:** EnCoach Engineering Team
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Executive Summary](#1-executive-summary)
|
||||||
|
2. [Project Scope and Objectives](#2-project-scope-and-objectives)
|
||||||
|
3. [System Architecture](#3-system-architecture)
|
||||||
|
4. [Feature Modules Breakdown](#4-feature-modules-breakdown)
|
||||||
|
5. [Development Phases](#5-development-phases)
|
||||||
|
6. [University Integration Plan](#6-university-integration-plan)
|
||||||
|
7. [Master Timeline](#7-master-timeline)
|
||||||
|
8. [Testing and Rollout Strategy](#8-testing-and-rollout-strategy)
|
||||||
|
9. [Team and Responsibilities](#9-team-and-responsibilities)
|
||||||
|
10. [Risk Register](#10-risk-register)
|
||||||
|
11. [Assumptions and Prerequisites](#11-assumptions-and-prerequisites)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
EnCoach is deploying a comprehensive AI-powered education platform for the University of Technology and Applied Sciences (UTAS). The platform delivers nine integrated modules spanning self-paced English learning, AI-graded examinations, Mathematics and IT courses with AI-assisted assessment, a full Learning Management System, university SIS integration, institutional whitelabeling, AI virtual tutors, and a support ticketing system.
|
||||||
|
|
||||||
|
### Platform at a Glance
|
||||||
|
|
||||||
|
| Dimension | Detail |
|
||||||
|
|-----------|--------|
|
||||||
|
| **Modules** | 9 (English Self Learning, Examinations, Math, IT, LMS, Whitelabeling, Humanisation, SIS Integration, Ticketing) |
|
||||||
|
| **AI Services** | 6 (OpenAI GPT-4o, Whisper STT, AWS Polly TTS, ELAI Avatars, GPTZero Detection, FAISS Semantic Search) |
|
||||||
|
| **Backend** | Odoo 19 -- 15 custom modules, 70+ REST API endpoints, PostgreSQL |
|
||||||
|
| **Frontend** | React 18 SPA -- 55+ pages, TypeScript, Tailwind CSS, shadcn/ui |
|
||||||
|
| **LMS Engine** | OpenEduCat (Odoo-native modules) |
|
||||||
|
| **Team** | 1 Solution Architect, 1 Senior Developer (AI-assisted), 1 Full-Stack Developer (AI-assisted) |
|
||||||
|
|
||||||
|
### Key Milestones
|
||||||
|
|
||||||
|
| Milestone | Target Date |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Project Kickoff | April 1, 2026 |
|
||||||
|
| Staging Demo to UTAS | April 24, 2026 |
|
||||||
|
| English Self Learning + Examinations -- GO LIVE | June 12, 2026 |
|
||||||
|
| Math + IT + LMS + Ticketing -- GO LIVE | July 24, 2026 |
|
||||||
|
| Full Platform GO LIVE (SIS, Whitelabeling, Humanisation) | August 21, 2026 |
|
||||||
|
|
||||||
|
### Current State of Development
|
||||||
|
|
||||||
|
The platform is not starting from zero. Significant engineering has already been completed:
|
||||||
|
|
||||||
|
- **Backend (Odoo 19):** 15 custom modules fully developed, covering exams, AI grading, content generation, media services, training, assignments, classrooms, subscriptions, registration, and ticketing. All AI services (OpenAI, Whisper, Polly, ELAI, GPTZero, FAISS) are integrated and configured. The backend is deployed and running on a staging server.
|
||||||
|
- **Frontend (React):** A complete UI prototype with 55+ pages across student, teacher, and admin portals. Role-based routing, layouts, and forms are built. Currently using mock data -- API integration is the primary remaining work.
|
||||||
|
- **Infrastructure:** Docker Compose deployment with PostgreSQL, staging environment operational at the current staging server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project Scope and Objectives
|
||||||
|
|
||||||
|
### 2.1 Objectives
|
||||||
|
|
||||||
|
1. **Deliver a production-ready AI-powered education platform** for UTAS covering English, Mathematics, and IT subjects.
|
||||||
|
2. **Integrate with UTAS's existing Student Information System (SIS)** for seamless student enrollment, grade sync, and attendance tracking.
|
||||||
|
3. **Provide institutional whitelabeling** with UTAS branding, colors, and identity.
|
||||||
|
4. **Deploy AI virtual tutors** (Humanisation) to enhance the student learning experience across subjects.
|
||||||
|
5. **Establish a scalable LMS** built on OpenEduCat for course management, timetabling, and academic administration.
|
||||||
|
|
||||||
|
### 2.2 Module Scope
|
||||||
|
|
||||||
|
| # | Module | Description | Acceptance Criteria |
|
||||||
|
|---|--------|-------------|---------------------|
|
||||||
|
| 1 | **English Self Learning** | AI-powered IELTS preparation: Reading, Listening, Writing, Speaking practice with AI grading, personalized training tips, and progress tracking. | Students can complete full practice sessions in all 4 skills; AI grading returns scores within 60 seconds; training tips are personalized to weaknesses. |
|
||||||
|
| 2 | **Examinations** | Full exam lifecycle: AI-generated exam content, timed exam sessions, automated grading, results dashboard, and teacher approval workflows. | Teachers can generate, review, and assign exams; students complete timed exams; grading is automated with band scores and feedback. |
|
||||||
|
| 3 | **Mathematics** | Math courses and AI-assisted exams: course content delivery, formula-based question generation, automated grading for numerical and short-answer responses. | Students access Math courses; AI generates Math questions at configurable difficulty levels; automated grading handles numerical and short-answer formats. |
|
||||||
|
| 4 | **Information Technology** | IT courses and AI-assisted exams: course content delivery, conceptual and practical question generation, automated grading for MCQ and short-answer formats. | Students access IT courses; AI generates IT questions across topics; automated grading covers MCQ, true/false, and short answers. |
|
||||||
|
| 5 | **LMS (OpenEduCat)** | Course management, enrollment, timetabling, attendance tracking, batch management, gradebook, and academic reporting via OpenEduCat Odoo modules. | Admin manages courses, batches, and timetables; teachers record attendance and grades; students view schedules and progress. |
|
||||||
|
| 6 | **Whitelabeling** | UTAS institutional branding: customizable logo, color palette, fonts, login screen, email templates, and report headers. | Platform displays UTAS branding throughout; branding is configurable via admin settings without code changes. |
|
||||||
|
| 7 | **Humanisation** | AI-powered virtual tutors using ELAI avatar technology: video-based tutoring, interactive speaking practice, and subject-specific AI guidance. | AI avatars present content and interact with students across subjects; avatar selection is available; videos generate within 2 minutes. |
|
||||||
|
| 8 | **SIS Integration** | Bidirectional integration with UTAS's existing Student Information System: student enrollment sync, grade export, attendance sync, and course catalog mapping. | Student data syncs from SIS to EnCoach on enrollment; grades and attendance export back to SIS; data is consistent within 15-minute sync cycles. |
|
||||||
|
| 9 | **Ticketing** | Support ticket system for students and staff: ticket creation, categorization, assignment, status tracking, and resolution workflow. | Users submit tickets; staff can categorize, assign, and resolve; status updates are visible to the reporter. |
|
||||||
|
|
||||||
|
### 2.3 Out of Scope (This Engagement)
|
||||||
|
|
||||||
|
- Mobile native applications (iOS/Android)
|
||||||
|
- Content creation for Math and IT courses (content to be provided by UTAS or generated via AI)
|
||||||
|
- UTAS internal network infrastructure changes
|
||||||
|
- Marketing website / landing page redesign
|
||||||
|
- Payment gateway integration for UTAS (institutional licensing assumed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. System Architecture
|
||||||
|
|
||||||
|
### 3.1 Architecture Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph frontend ["Frontend Layer"]
|
||||||
|
ReactApp["React 18 SPA<br/>TypeScript + Tailwind + shadcn/ui<br/>55+ pages, role-based routing"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph backend ["Backend Layer -- Odoo 19"]
|
||||||
|
API["REST API Controllers<br/>70+ JSON endpoints"]
|
||||||
|
Core["Core Modules<br/>Users, Entities, Roles, Permissions"]
|
||||||
|
Exam["Exam Engine<br/>Reading, Listening, Writing, Speaking, Level"]
|
||||||
|
LMS["OpenEduCat LMS<br/>Courses, Batches, Timetable, Attendance"]
|
||||||
|
AI["AI Modules<br/>Generation, Grading, Media, Training"]
|
||||||
|
Sub["Business Modules<br/>Subscriptions, Tickets, Registration"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph aiServices ["AI Services"]
|
||||||
|
GPT["OpenAI GPT-4o<br/>Content Generation + Grading"]
|
||||||
|
Whisper["OpenAI Whisper<br/>Speech-to-Text (local)"]
|
||||||
|
Polly["AWS Polly<br/>Text-to-Speech (neural)"]
|
||||||
|
ELAI["ELAI<br/>AI Avatar Video"]
|
||||||
|
GPTZero["GPTZero<br/>AI Writing Detection"]
|
||||||
|
FAISS["FAISS + SentenceTransformers<br/>Semantic Search"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph dataLayer ["Data Layer"]
|
||||||
|
PG["PostgreSQL 16"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph external ["External Systems"]
|
||||||
|
SIS["UTAS SIS<br/>Student Information System"]
|
||||||
|
end
|
||||||
|
|
||||||
|
ReactApp -->|"JWT Auth + REST API"| API
|
||||||
|
API --> Core
|
||||||
|
API --> Exam
|
||||||
|
API --> LMS
|
||||||
|
API --> AI
|
||||||
|
API --> Sub
|
||||||
|
AI --> GPT
|
||||||
|
AI --> Whisper
|
||||||
|
AI --> Polly
|
||||||
|
AI --> ELAI
|
||||||
|
AI --> GPTZero
|
||||||
|
AI --> FAISS
|
||||||
|
Core --> PG
|
||||||
|
Exam --> PG
|
||||||
|
LMS --> PG
|
||||||
|
Sub --> PG
|
||||||
|
API <-->|"REST API Sync"| SIS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Details |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| **Frontend** | React 18, TypeScript, Vite 5 | SPA with TanStack Query, React Router v6, Zod validation |
|
||||||
|
| **UI Framework** | shadcn/ui, Tailwind CSS | Radix UI primitives, responsive design, dark mode capable |
|
||||||
|
| **Backend** | Odoo 19, Python 3.11 | 15 custom modules, ORM-based data access, background job processing |
|
||||||
|
| **Database** | PostgreSQL 16 | Odoo-managed schema, full-text search, JSONB for flexible data |
|
||||||
|
| **LMS** | OpenEduCat | Odoo-native education modules: courses, batches, attendance, timetable |
|
||||||
|
| **AI -- Text Generation** | OpenAI GPT-4o, GPT-3.5-turbo | Exam content generation, grading, evaluation, training tips |
|
||||||
|
| **AI -- Speech-to-Text** | OpenAI Whisper (local, base model) | 4 instances, round-robin, 30s chunked transcription |
|
||||||
|
| **AI -- Text-to-Speech** | AWS Polly (Neural) | 11 English voices, MP3 output, sentence-boundary chunking |
|
||||||
|
| **AI -- Video** | ELAI | 7 AI avatars (male/female), ElevenLabs + Azure voices |
|
||||||
|
| **AI -- Detection** | GPTZero | AI-generated writing detection per submission |
|
||||||
|
| **AI -- Search** | FAISS + all-MiniLM-L6-v2 | Semantic similarity search over training tips knowledge base |
|
||||||
|
| **Deployment** | Docker Compose | Odoo + PostgreSQL containers, Nginx reverse proxy |
|
||||||
|
| **Version Control** | Gitea | Private repositories for frontend and backend |
|
||||||
|
|
||||||
|
### 3.3 Deployment Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
subgraph production ["Production Server"]
|
||||||
|
Nginx["Nginx<br/>Reverse Proxy<br/>SSL Termination"]
|
||||||
|
FrontendContainer["Frontend Container<br/>React SPA<br/>Port 3000"]
|
||||||
|
OdooContainer["Odoo 19 Container<br/>Python 3.11<br/>Port 8069"]
|
||||||
|
DBContainer["PostgreSQL 16<br/>Port 5432"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Browser["Browser"] -->|"HTTPS"| Nginx
|
||||||
|
Nginx -->|"/app/*"| FrontendContainer
|
||||||
|
Nginx -->|"/api/*"| OdooContainer
|
||||||
|
OdooContainer --> DBContainer
|
||||||
|
FrontendContainer -->|"REST API"| OdooContainer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Existing Assets Inventory
|
||||||
|
|
||||||
|
| Asset | Count | Status |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Odoo custom modules | 15 | Deployed on staging |
|
||||||
|
| Backend REST API endpoints | 70+ | Functional, English/IELTS focused |
|
||||||
|
| Frontend pages (React) | 55+ | UI complete, mock data only |
|
||||||
|
| AI service integrations | 6 | Configured and tested |
|
||||||
|
| ELAI avatar profiles | 7 | Seeded in database |
|
||||||
|
| Training tips (FAISS) | 80 | Imported with embeddings |
|
||||||
|
| Odoo data models | 20+ | Schema deployed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Feature Modules Breakdown
|
||||||
|
|
||||||
|
### 4.1 English Self Learning
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | Backend complete: AI-powered generation for Reading, Listening, Writing, Speaking. Grading via GPT-4o with IELTS rubric. Polly TTS for listening audio. ELAI avatars for speaking. FAISS-based personalized training tips. Frontend UI built (mocked). |
|
||||||
|
| **Work Required** | Replace mock authentication with Odoo JWT. Wire all student-facing pages to backend APIs. Integrate AI grading display (scores, feedback, AI detection). Connect training/tips module. |
|
||||||
|
| **Dependencies** | Stable Odoo API (Phase 1 complete). Frontend auth working. |
|
||||||
|
| **Effort** | 6 person-weeks |
|
||||||
|
| **Risk** | Low -- both backend and frontend exist; integration is the main task. |
|
||||||
|
|
||||||
|
### 4.2 Examinations
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | Backend complete: exam CRUD, 5 module types (reading, listening, writing, speaking, level), AI generation endpoints, approval workflows. Frontend: exam editor, exam list, generation page, approval workflows (all mocked). |
|
||||||
|
| **Work Required** | Wire admin exam management pages to APIs. Connect AI generation UI to real endpoints. Implement exam-taking flow with timer and state persistence. Connect grading pipeline and results display. |
|
||||||
|
| **Dependencies** | English Self Learning module (shared components). |
|
||||||
|
| **Effort** | Included in English Self Learning estimate (shared codebase). |
|
||||||
|
| **Risk** | Low -- core exam engine is proven. |
|
||||||
|
|
||||||
|
### 4.3 Mathematics
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | No backend module exists. No frontend pages exist. The AI exam engine (GPT-based generation + grading) provides a reusable foundation. |
|
||||||
|
| **Work Required** | **Backend:** Create `encoach_math` Odoo module with Math-specific exam models, AI prompt templates for formula-based questions, numerical grading logic, and multi-format question types (MCQ, fill-in, short answer, calculation). **Frontend:** Create Math course pages, Math exam taking interface with formula rendering (KaTeX/MathJax), Math-specific grading display. |
|
||||||
|
| **Dependencies** | Core exam engine must be stable. AI prompts require testing with GPT-4o for mathematical accuracy. |
|
||||||
|
| **Effort** | 5 person-weeks |
|
||||||
|
| **Risk** | Medium -- AI generation of math questions requires careful prompt engineering for accuracy. Formula rendering on frontend adds complexity. |
|
||||||
|
|
||||||
|
### 4.4 Information Technology
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | No backend module exists. No frontend pages exist. Similar to Math, the exam engine provides a foundation. |
|
||||||
|
| **Work Required** | **Backend:** Create `encoach_it` Odoo module with IT-specific exam models, AI prompt templates for conceptual and practical IT questions (networking, databases, programming concepts, security), grading logic for MCQ and short-answer. **Frontend:** Create IT course pages, IT exam interface, IT-specific grading display. |
|
||||||
|
| **Dependencies** | Core exam engine must be stable. |
|
||||||
|
| **Effort** | 4 person-weeks |
|
||||||
|
| **Risk** | Low-Medium -- IT subjects are text-based (no formula complexity). AI generation of IT questions is straightforward with GPT-4o. |
|
||||||
|
|
||||||
|
### 4.5 LMS (OpenEduCat)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | OpenEduCat modules will be provided and installed into Odoo 19. Frontend LMS pages exist and are fully built: student dashboard, courses, course detail, assignments, grades, attendance, timetable (student); courses, course builder, assignments, attendance, students, timetable (teacher); dashboard, courses, students, teachers, batches, batch detail, timetable, reports, settings (admin). All pages use mock data. |
|
||||||
|
| **Work Required** | **Backend:** Install OpenEduCat modules. Create REST API controller layer (`encoach_lms_api`) to expose OpenEduCat models as JSON endpoints matching the frontend's data expectations. Map OpenEduCat models to frontend data interfaces (Course, Module, Lesson, Batch, Timetable, Attendance, Grade). **Frontend:** Replace mock data imports with API calls using TanStack Query. Wire forms to create/update APIs. |
|
||||||
|
| **Dependencies** | OpenEduCat modules provided and compatible with Odoo 19. |
|
||||||
|
| **Effort** | 3 person-weeks |
|
||||||
|
| **Risk** | Medium -- OpenEduCat model compatibility with Odoo 19 must be verified. API mapping between OpenEduCat's data structures and the frontend's interfaces requires careful alignment. |
|
||||||
|
|
||||||
|
### 4.6 Whitelabeling
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | No whitelabeling support. Frontend uses CSS variables for theming (Tailwind + shadcn/ui) with `darkMode: ["class"]` configured but not activated. No branding configuration. |
|
||||||
|
| **Work Required** | **Backend:** Create `encoach_branding` model in Odoo for tenant branding settings (logo, primary color, secondary color, font, favicon, login background, email header). Expose branding API endpoint. **Frontend:** Create a branding provider that fetches configuration on app load and applies CSS variables dynamically. Configure logo, colors, and fonts from API. Add admin UI for branding management. |
|
||||||
|
| **Dependencies** | UTAS provides brand assets (logo, color palette, fonts). |
|
||||||
|
| **Effort** | 2 person-weeks |
|
||||||
|
| **Risk** | Low -- CSS variable-based theming is well-established. |
|
||||||
|
|
||||||
|
### 4.7 Humanisation (AI Virtual Tutors)
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | ELAI integration exists for speaking exam avatar videos. 7 avatars configured (Gia, Vadim, Orhan, Flora, Scarlett, Parker, Ethan) with ElevenLabs and Azure voices. Currently limited to presenting speaking exam questions. |
|
||||||
|
| **Work Required** | **Backend:** Extend ELAI integration to support tutoring mode -- avatars explain concepts, provide feedback narration, and guide students through exercises. Create endpoints for on-demand tutoring video generation per subject. **Frontend:** Build tutor interaction UI -- avatar selector, video player with synchronized content, interactive Q&A flow. Integrate across English, Math, and IT modules. |
|
||||||
|
| **Dependencies** | ELAI API capacity and cost model for increased video generation. Subject-specific scripts/prompts. |
|
||||||
|
| **Effort** | 3 person-weeks |
|
||||||
|
| **Risk** | Medium -- ELAI video generation time (1-3 minutes per video) affects real-time tutoring UX. Cost scales with usage. |
|
||||||
|
|
||||||
|
### 4.8 SIS Integration
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | No SIS integration exists. UTAS has an existing Student Information System that we must integrate with via API. |
|
||||||
|
| **Work Required** | **Discovery:** Obtain UTAS SIS API documentation, authentication method, and data schemas. **Backend:** Create `encoach_sis` Odoo module with SIS sync models, scheduled sync jobs (cron), and conflict resolution. Implement: student enrollment import, grade export, attendance sync, course catalog mapping. **Frontend:** SIS sync status dashboard for admin. Sync logs and error reporting. |
|
||||||
|
| **Dependencies** | UTAS provides SIS API documentation, test environment access, and a technical contact. |
|
||||||
|
| **Effort** | 4 person-weeks |
|
||||||
|
| **Risk** | High -- entirely dependent on UTAS SIS API quality, documentation completeness, and test environment availability. This is the single highest-risk module. |
|
||||||
|
|
||||||
|
### 4.9 Ticketing
|
||||||
|
|
||||||
|
| Aspect | Detail |
|
||||||
|
|--------|--------|
|
||||||
|
| **Current State** | Backend complete: `encoach_ticket` Odoo module with full CRUD, categorization, and status workflow. API endpoints exist (`/api/tickets`). Frontend: Tickets page exists in admin portal (mocked). |
|
||||||
|
| **Work Required** | Wire frontend tickets page to backend API. Add ticket creation flow for students and teachers. Add ticket assignment and resolution workflow for admin. |
|
||||||
|
| **Dependencies** | None -- self-contained module. |
|
||||||
|
| **Effort** | 1 person-week |
|
||||||
|
| **Risk** | Low -- both backend and frontend exist. |
|
||||||
|
|
||||||
|
### 4.10 Effort Summary
|
||||||
|
|
||||||
|
| Module | Person-Weeks | Wave |
|
||||||
|
|--------|-------------|------|
|
||||||
|
| English Self Learning + Examinations | 6 | Wave 1 |
|
||||||
|
| Mathematics | 5 | Wave 2 |
|
||||||
|
| Information Technology | 4 | Wave 2 |
|
||||||
|
| LMS (OpenEduCat) | 3 | Wave 2 |
|
||||||
|
| Ticketing | 1 | Wave 2 |
|
||||||
|
| Whitelabeling | 2 | Wave 3 |
|
||||||
|
| Humanisation | 3 | Wave 3 |
|
||||||
|
| SIS Integration | 4 | Wave 3 |
|
||||||
|
| **Total** | **28 person-weeks** | |
|
||||||
|
|
||||||
|
With a team of 3 producing approximately 3 person-weeks per calendar week, the raw work is approximately 9-10 calendar weeks. Adding UAT cycles, amendments, and buffer, the overall calendar duration is 20 weeks (April 1 -- August 21).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Development Phases
|
||||||
|
|
||||||
|
The project is structured into three overlapping waves. Each wave follows a build-test-deploy cycle and produces a production-ready deliverable.
|
||||||
|
|
||||||
|
### 5.1 Wave 1 -- Foundation (April 1 -- June 12)
|
||||||
|
|
||||||
|
**Goal:** English Self Learning and Examinations modules live in production with real UTAS users.
|
||||||
|
|
||||||
|
This wave transforms the existing backend and frontend from separate prototypes into an integrated, production-ready platform.
|
||||||
|
|
||||||
|
#### Sprint Breakdown
|
||||||
|
|
||||||
|
| Sprint | Dates | Focus | Deliverables |
|
||||||
|
|--------|-------|-------|-------------|
|
||||||
|
| **S1** | Apr 1-11 | **Stabilization & Auth** | Fix remaining backend bugs. Replace mock auth with Odoo JWT in React frontend. Set up API service layer. Configure production deployment pipeline. |
|
||||||
|
| **S2** | Apr 12-18 | **Core API Integration** | Wire student dashboard, courses, and profile pages. Implement exam list and exam detail APIs. Connect user management and entity management. |
|
||||||
|
| **S3** | Apr 19-25 | **Exam Engine Integration** | Wire exam-taking flow (reading, listening, writing, speaking). Connect AI generation endpoints to exam editor. Integrate timer, state persistence, and submission. **Staging demo to UTAS (Apr 24-25).** |
|
||||||
|
| **S4** | Apr 26 -- May 9 | **AI Features & Grading** | Wire AI grading pipeline (writing evaluation, speaking evaluation). Connect TTS audio playback. Connect ELAI avatar videos. Integrate training tips and personalized recommendations. Connect AI detection display. |
|
||||||
|
| **S5** | May 10-23 | **UAT Round 1** | Deploy to UTAS staging. Conduct UAT with UTAS staff and selected students. Collect feedback. Bug fixes and amendments. |
|
||||||
|
| **S6** | May 24 -- Jun 6 | **UAT Round 2 & Hardening** | Address UAT feedback. Performance optimization. Security review. Documentation. |
|
||||||
|
| **S7** | Jun 7-12 | **GO LIVE** | Production deployment. Monitoring setup. Handover to UTAS for English + Examinations. Post-launch support begins. |
|
||||||
|
|
||||||
|
#### Wave 1 Exit Criteria
|
||||||
|
|
||||||
|
- Students can register, log in, and complete full English practice sessions
|
||||||
|
- AI grading returns accurate band scores with detailed feedback for writing and speaking
|
||||||
|
- Teachers can generate, review, approve, and assign exams
|
||||||
|
- Admin can manage users, entities, classrooms, and assignments
|
||||||
|
- Training tips are personalized based on exam performance
|
||||||
|
- Platform is stable under expected UTAS load
|
||||||
|
|
||||||
|
### 5.2 Wave 2 -- Expansion (May 17 -- July 24)
|
||||||
|
|
||||||
|
**Goal:** Mathematics, Information Technology, LMS, and Ticketing modules live in production.
|
||||||
|
|
||||||
|
Wave 2 begins during Wave 1's UAT phase, leveraging developers freed from active feature work.
|
||||||
|
|
||||||
|
#### Sprint Breakdown
|
||||||
|
|
||||||
|
| Sprint | Dates | Focus | Deliverables |
|
||||||
|
|--------|-------|-------|-------------|
|
||||||
|
| **S8** | May 17-30 | **Math & IT Backend** | Create `encoach_math` and `encoach_it` Odoo modules. Develop AI prompt templates for Math question generation (numerical, formula-based, word problems). Develop AI prompt templates for IT questions (conceptual, practical). Implement grading logic for non-essay formats. |
|
||||||
|
| **S9** | May 31 -- Jun 13 | **OpenEduCat & LMS API** | Install and configure OpenEduCat modules in Odoo 19. Create `encoach_lms_api` controller layer. Map OpenEduCat models to frontend data interfaces. Wire LMS admin pages (courses, batches, timetable, attendance). |
|
||||||
|
| **S10** | Jun 14-27 | **Math & IT Frontend + LMS Wiring** | Build Math exam interface with formula rendering. Build IT exam interface. Wire student and teacher LMS pages. Wire ticketing module. |
|
||||||
|
| **S11** | Jun 28 -- Jul 11 | **UAT Round 1** | Deploy Math, IT, LMS, Ticketing to staging. UAT with UTAS staff. Math AI accuracy testing. LMS workflow validation. |
|
||||||
|
| **S12** | Jul 12-18 | **Amendments** | Address UAT feedback. Fix Math/IT grading edge cases. LMS refinements. |
|
||||||
|
| **S13** | Jul 19-24 | **GO LIVE** | Production deployment of Math, IT, LMS, Ticketing. Post-launch support begins. |
|
||||||
|
|
||||||
|
#### Wave 2 Exit Criteria
|
||||||
|
|
||||||
|
- Math and IT exams generate accurately via AI with appropriate difficulty levels
|
||||||
|
- LMS (OpenEduCat) fully functional: courses, enrollment, attendance, timetable, gradebook
|
||||||
|
- Ticketing system operational for students and staff
|
||||||
|
- All modules integrated with Wave 1 platform (single sign-on, unified navigation)
|
||||||
|
|
||||||
|
### 5.3 Wave 3 -- Integration (July 1 -- August 21)
|
||||||
|
|
||||||
|
**Goal:** Whitelabeling, SIS integration, and Humanisation modules complete. Full platform go-live.
|
||||||
|
|
||||||
|
Wave 3 begins during Wave 2's frontend phase, with SIS discovery starting earlier.
|
||||||
|
|
||||||
|
#### Sprint Breakdown
|
||||||
|
|
||||||
|
| Sprint | Dates | Focus | Deliverables |
|
||||||
|
|--------|-------|-------|-------------|
|
||||||
|
| **S14** | Jul 1-11 | **Whitelabeling + SIS Discovery** | Implement branding configuration backend and frontend. Apply UTAS brand assets. Begin SIS API discovery and documentation review with UTAS IT team. |
|
||||||
|
| **S15** | Jul 12-25 | **SIS Integration Development** | Build `encoach_sis` Odoo module. Implement student enrollment import. Implement grade export. Implement attendance sync. Build admin SIS dashboard. |
|
||||||
|
| **S16** | Jul 26 -- Aug 1 | **Humanisation** | Extend ELAI integration for tutoring mode. Build tutor interaction UI. Generate subject-specific avatar tutoring content. |
|
||||||
|
| **S17** | Aug 2-8 | **Integration Testing** | End-to-end testing of all 9 modules together. SIS sync validation with UTAS test data. Performance testing under load. Security audit. |
|
||||||
|
| **S18** | Aug 9-15 | **Final UAT** | Full platform UAT with UTAS. All modules tested together. Final amendments. |
|
||||||
|
| **S19** | Aug 16-21 | **Full Platform GO LIVE** | Production deployment of complete platform. All 9 modules active. Monitoring and support in place. |
|
||||||
|
|
||||||
|
#### Wave 3 Exit Criteria
|
||||||
|
|
||||||
|
- Platform displays UTAS branding throughout
|
||||||
|
- SIS syncs student enrollment, grades, and attendance bidirectionally
|
||||||
|
- AI virtual tutors available across English, Math, and IT
|
||||||
|
- All 9 modules function as an integrated platform
|
||||||
|
- Performance meets production requirements under expected load
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. University Integration Plan
|
||||||
|
|
||||||
|
### 6.1 Integration Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant SIS as UTAS SIS
|
||||||
|
participant Odoo as EnCoach Odoo 19
|
||||||
|
participant React as EnCoach Frontend
|
||||||
|
participant Student as Student
|
||||||
|
|
||||||
|
Note over SIS,Odoo: Enrollment Sync (SIS → EnCoach)
|
||||||
|
SIS->>Odoo: Student enrollment data (scheduled sync)
|
||||||
|
Odoo->>Odoo: Create/update student records
|
||||||
|
Odoo->>Odoo: Assign to courses and batches
|
||||||
|
|
||||||
|
Note over Student,React: Daily Usage
|
||||||
|
Student->>React: Login, take courses, complete exams
|
||||||
|
React->>Odoo: API calls (exam, grading, attendance)
|
||||||
|
Odoo->>Odoo: Record grades, attendance, progress
|
||||||
|
|
||||||
|
Note over Odoo,SIS: Grade & Attendance Export (EnCoach → SIS)
|
||||||
|
Odoo->>SIS: Grade export (scheduled sync)
|
||||||
|
Odoo->>SIS: Attendance export (scheduled sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Data Exchange Specification
|
||||||
|
|
||||||
|
| Data Flow | Direction | Frequency | Format |
|
||||||
|
|-----------|-----------|-----------|--------|
|
||||||
|
| Student enrollment | SIS → EnCoach | On enrollment + daily sync | REST API / CSV import |
|
||||||
|
| Course catalog mapping | Bidirectional | On setup + manual sync | REST API |
|
||||||
|
| Exam grades | EnCoach → SIS | After grading + daily batch | REST API |
|
||||||
|
| Attendance records | EnCoach → SIS | End of day batch | REST API |
|
||||||
|
| Student status changes | SIS → EnCoach | Real-time webhook or daily | REST API |
|
||||||
|
|
||||||
|
### 6.3 Authentication Strategy
|
||||||
|
|
||||||
|
| Phase | Method | Detail |
|
||||||
|
|-------|--------|--------|
|
||||||
|
| **Initial (Wave 1-2)** | Odoo-native JWT | Students and staff authenticate directly with EnCoach. Credentials provisioned from SIS enrollment data. |
|
||||||
|
| **Full Integration (Wave 3)** | SSO via SAML/OAuth | If UTAS SIS supports SSO, students authenticate once at UTAS portal and access EnCoach seamlessly. Fallback to JWT if SSO is not available. |
|
||||||
|
|
||||||
|
### 6.4 UTAS Responsibilities for Integration
|
||||||
|
|
||||||
|
| Deliverable | Required By | Impact if Delayed |
|
||||||
|
|-------------|-------------|-------------------|
|
||||||
|
| SIS API documentation | June 15, 2026 | Blocks SIS module development; delays Wave 3 by equivalent duration |
|
||||||
|
| SIS test environment access | July 1, 2026 | Blocks integration testing |
|
||||||
|
| SIS technical contact (dedicated) | June 15, 2026 | Slows discovery and issue resolution |
|
||||||
|
| Brand assets (logo, colors, fonts) | June 15, 2026 | Blocks whitelabeling completion |
|
||||||
|
| UAT testers (3-5 staff, 10-20 students) | May 1, 2026 | Delays UAT cycles; risks quality |
|
||||||
|
| Math and IT course content (if not AI-generated) | May 15, 2026 | Delays Math/IT module population |
|
||||||
|
| Production server provisioning | August 1, 2026 | Blocks production deployment |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Master Timeline
|
||||||
|
|
||||||
|
### 7.1 Gantt Chart
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title EnCoach UTAS Deployment -- Master Timeline
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
axisFormat %d %b
|
||||||
|
|
||||||
|
section Wave1_Foundation
|
||||||
|
S1_Stabilize_Auth :s1, 2026-04-01, 11d
|
||||||
|
S2_Core_API_Integration :s2, 2026-04-12, 7d
|
||||||
|
S3_Exam_Engine_Integration :s3, 2026-04-19, 7d
|
||||||
|
UTAS_Staging_Demo :milestone, m1, 2026-04-24, 0d
|
||||||
|
S4_AI_Features_Grading :s4, 2026-04-26, 14d
|
||||||
|
S5_UAT_Round_1 :s5, 2026-05-10, 14d
|
||||||
|
S6_UAT_Round_2_Hardening :s6, 2026-05-24, 14d
|
||||||
|
S7_English_Exams_GO_LIVE :s7, 2026-06-07, 5d
|
||||||
|
English_Exams_LIVE :milestone, m2, 2026-06-12, 0d
|
||||||
|
|
||||||
|
section Wave2_Expansion
|
||||||
|
S8_Math_IT_Backend :s8, 2026-05-17, 14d
|
||||||
|
S9_OpenEduCat_LMS_API :s9, 2026-05-31, 14d
|
||||||
|
S10_Math_IT_LMS_Frontend :s10, 2026-06-14, 14d
|
||||||
|
S11_UAT_Math_IT_LMS :s11, 2026-06-28, 14d
|
||||||
|
S12_Amendments :s12, 2026-07-12, 7d
|
||||||
|
S13_Math_IT_LMS_GO_LIVE :s13, 2026-07-19, 5d
|
||||||
|
Math_IT_LMS_LIVE :milestone, m3, 2026-07-24, 0d
|
||||||
|
|
||||||
|
section Wave3_Integration
|
||||||
|
S14_Whitelabeling_SIS_Discovery :s14, 2026-07-01, 11d
|
||||||
|
S15_SIS_Integration :s15, 2026-07-12, 14d
|
||||||
|
S16_Humanisation :s16, 2026-07-26, 7d
|
||||||
|
S17_Integration_Testing :s17, 2026-08-02, 7d
|
||||||
|
S18_Final_UAT :s18, 2026-08-09, 7d
|
||||||
|
S19_Full_Platform_GO_LIVE :s19, 2026-08-16, 5d
|
||||||
|
Full_Platform_LIVE :milestone, m4, 2026-08-21, 0d
|
||||||
|
|
||||||
|
section PostLaunch
|
||||||
|
Post_Launch_Support :pls, 2026-08-21, 30d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 Key Milestones
|
||||||
|
|
||||||
|
| # | Milestone | Date | Gate Criteria |
|
||||||
|
|---|-----------|------|---------------|
|
||||||
|
| M0 | Project Kickoff | April 1, 2026 | Team assembled, scope agreed, environments ready |
|
||||||
|
| M1 | UTAS Staging Demo | April 24, 2026 | English + Exams functional on staging, basic end-to-end flow |
|
||||||
|
| M2 | Wave 1 GO LIVE | June 12, 2026 | English Self Learning + Examinations in production, 2 UAT rounds passed |
|
||||||
|
| M3 | Wave 2 GO LIVE | July 24, 2026 | Math + IT + LMS + Ticketing in production, UAT passed |
|
||||||
|
| M4 | Full Platform GO LIVE | August 21, 2026 | All 9 modules in production, SIS integration validated, full UAT passed |
|
||||||
|
| M5 | Post-Launch Support Ends | September 21, 2026 | 30-day support period complete, handover documentation delivered |
|
||||||
|
|
||||||
|
### 7.3 Critical Path
|
||||||
|
|
||||||
|
The critical path runs through:
|
||||||
|
|
||||||
|
1. **Wave 1 auth stabilization** (blocks all frontend work)
|
||||||
|
2. **Wave 1 API integration** (blocks UAT and all subsequent waves)
|
||||||
|
3. **UTAS SIS API documentation delivery** (blocks Wave 3 SIS development)
|
||||||
|
4. **OpenEduCat module compatibility** (blocks LMS development)
|
||||||
|
|
||||||
|
Any delay on items 1-2 cascades through the entire timeline. Items 3-4 are external dependencies that must be tracked proactively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing and Rollout Strategy
|
||||||
|
|
||||||
|
### 8.1 Testing Stages
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
Dev["Developer Testing<br/>Unit + Integration<br/>Continuous"] --> Staging["Staging<br/>System Integration<br/>Per Sprint"]
|
||||||
|
Staging --> UAT["UAT with UTAS<br/>Acceptance<br/>Per Wave"]
|
||||||
|
UAT --> Perf["Performance Testing<br/>Load + Stress<br/>Pre-Go-Live"]
|
||||||
|
Perf --> Security["Security Audit<br/>OWASP Top 10<br/>Pre-Go-Live"]
|
||||||
|
Security --> GoLive["GO LIVE<br/>Production<br/>Per Wave"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stage | Who | When | Scope |
|
||||||
|
|-------|-----|------|-------|
|
||||||
|
| **Developer Testing** | EnCoach dev team | Continuous | Unit tests, API integration tests, manual smoke tests per feature |
|
||||||
|
| **Staging Deployment** | EnCoach dev team | End of each sprint | Full platform on staging server, regression testing, cross-module integration |
|
||||||
|
| **UAT (User Acceptance)** | UTAS staff + students | 2 rounds per wave | Guided test scenarios, real-world usage, feedback collection |
|
||||||
|
| **Performance Testing** | EnCoach dev team | Before each go-live | Concurrent user simulation (target: 200 simultaneous users), API response times (<2s for standard, <60s for AI grading) |
|
||||||
|
| **Security Audit** | EnCoach architect | Before production go-live | Authentication review, authorization checks, API input validation, OWASP Top 10 |
|
||||||
|
|
||||||
|
### 8.2 UAT Process
|
||||||
|
|
||||||
|
Each wave includes two UAT rounds:
|
||||||
|
|
||||||
|
**UAT Round 1 (2 weeks):**
|
||||||
|
1. EnCoach deploys to staging environment
|
||||||
|
2. UTAS UAT team receives test accounts and test scenarios document
|
||||||
|
3. UTAS testers execute scenarios and log issues via shared tracking system
|
||||||
|
4. EnCoach triages issues daily (critical = same day, high = 2 days, medium = next sprint)
|
||||||
|
5. Weekly status call between EnCoach and UTAS
|
||||||
|
|
||||||
|
**UAT Round 2 (2 weeks):**
|
||||||
|
1. All critical and high issues from Round 1 resolved
|
||||||
|
2. Re-test of failed scenarios
|
||||||
|
3. Exploratory testing by UTAS testers
|
||||||
|
4. Go-live readiness assessment
|
||||||
|
5. Sign-off by UTAS project lead
|
||||||
|
|
||||||
|
### 8.3 Go-Live Checklist (Per Wave)
|
||||||
|
|
||||||
|
| # | Item | Owner |
|
||||||
|
|---|------|-------|
|
||||||
|
| 1 | All critical and high UAT issues resolved | EnCoach |
|
||||||
|
| 2 | Performance tests pass (response time, concurrent users) | EnCoach |
|
||||||
|
| 3 | Security audit complete, no critical findings | EnCoach |
|
||||||
|
| 4 | Production environment provisioned and configured | EnCoach + UTAS IT |
|
||||||
|
| 5 | DNS / domain configured (if applicable) | UTAS IT |
|
||||||
|
| 6 | SSL certificate installed | EnCoach + UTAS IT |
|
||||||
|
| 7 | Database backup and restore procedure tested | EnCoach |
|
||||||
|
| 8 | Monitoring and alerting configured | EnCoach |
|
||||||
|
| 9 | Rollback procedure documented and tested | EnCoach |
|
||||||
|
| 10 | UTAS project lead sign-off | UTAS |
|
||||||
|
|
||||||
|
### 8.4 Rollback Plan
|
||||||
|
|
||||||
|
Each go-live deployment includes a documented rollback procedure:
|
||||||
|
|
||||||
|
1. **Database snapshot** taken immediately before production deployment
|
||||||
|
2. **Previous container images** tagged and retained (minimum 3 previous versions)
|
||||||
|
3. **Rollback trigger criteria:** >5% error rate, >10s average response time, data integrity issue
|
||||||
|
4. **Rollback execution time:** <15 minutes (Docker image swap + database restore)
|
||||||
|
5. **Communication:** UTAS IT notified within 5 minutes of rollback decision
|
||||||
|
|
||||||
|
### 8.5 Post-Launch Support
|
||||||
|
|
||||||
|
| Period | Coverage | Scope |
|
||||||
|
|--------|----------|-------|
|
||||||
|
| Week 1-2 after each go-live | Daily monitoring, same-day critical fixes | Bug fixes, performance tuning, user support |
|
||||||
|
| Week 3-4 after each go-live | Standard monitoring, 2-day response for non-critical | Bug fixes, minor enhancements from feedback |
|
||||||
|
| After Full Platform GO LIVE | 30-day support period (Aug 21 -- Sep 21) | Full platform support, knowledge transfer to UTAS IT |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Team and Responsibilities
|
||||||
|
|
||||||
|
### 9.1 EnCoach Team
|
||||||
|
|
||||||
|
| Role | Responsibilities |
|
||||||
|
|------|-----------------|
|
||||||
|
| **Solution Architect** | System architecture decisions. University integration design. SIS API mapping. Security and performance oversight. Code review. UTAS stakeholder communication. Sprint planning. |
|
||||||
|
| **Senior Developer (AI-assisted)** | Backend development (Odoo modules). AI service integration. Math/IT module AI prompts. SIS integration implementation. Database design. API development. |
|
||||||
|
| **Full-Stack Developer (AI-assisted)** | Frontend development (React). API integration. UI/UX implementation. Whitelabeling. LMS frontend wiring. Testing. |
|
||||||
|
|
||||||
|
### 9.2 UTAS Responsibilities
|
||||||
|
|
||||||
|
| Role | Responsibilities |
|
||||||
|
|------|-----------------|
|
||||||
|
| **Project Sponsor** | Budget approval. Strategic decisions. Escalation resolution. |
|
||||||
|
| **Project Lead** | Scope sign-off. UAT coordination. Go-live approval. Weekly status reviews. |
|
||||||
|
| **IT Department** | SIS API documentation and test environment. Production server provisioning. Network and DNS configuration. SSL certificates. |
|
||||||
|
| **Subject Matter Experts** | Math and IT course content review. Exam quality validation. Training content verification. |
|
||||||
|
| **UAT Testers (3-5 staff, 10-20 students)** | Execute test scenarios. Log issues and feedback. Re-test after fixes. |
|
||||||
|
|
||||||
|
### 9.3 Communication Plan
|
||||||
|
|
||||||
|
| Activity | Frequency | Participants | Medium |
|
||||||
|
|----------|-----------|-------------|--------|
|
||||||
|
| Sprint status report | Weekly (Friday) | EnCoach team → UTAS Project Lead | Email + document |
|
||||||
|
| Demo / walkthrough | Bi-weekly | EnCoach team + UTAS stakeholders | Video call + screen share |
|
||||||
|
| UAT feedback review | Daily during UAT | EnCoach team + UTAS testers | Shared issue tracker |
|
||||||
|
| Escalation call | As needed | Architect + UTAS Project Lead | Video call |
|
||||||
|
| Go-live readiness review | Before each go-live | All stakeholders | Meeting |
|
||||||
|
|
||||||
|
### 9.4 Escalation Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Level 1: Developer → Solution Architect (same day)
|
||||||
|
Level 2: Solution Architect → UTAS Project Lead (1 business day)
|
||||||
|
Level 3: UTAS Project Lead → UTAS Project Sponsor (2 business days)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risk Register
|
||||||
|
|
||||||
|
| # | Risk | Probability | Impact | Mitigation |
|
||||||
|
|---|------|------------|--------|------------|
|
||||||
|
| R1 | **UTAS SIS API documentation delayed or incomplete** | High | High | Begin SIS discovery in Wave 2 (early July). Prepare alternative sync methods (CSV import/export) as fallback. Weekly check-ins with UTAS IT from June onwards. |
|
||||||
|
| R2 | **Math AI question generation inaccuracy** | Medium | Medium | Invest in thorough prompt engineering with GPT-4o. Build validation test suite with known-correct Math problems. Include human review step in exam approval workflow. |
|
||||||
|
| R3 | **OpenEduCat incompatibility with Odoo 19** | Medium | High | Test OpenEduCat installation early (Sprint S9). Identify compatibility issues before committing to LMS frontend work. Fallback: build minimal LMS models within existing Odoo modules. |
|
||||||
|
| R4 | **Frontend API integration slower than estimated** | Medium | Medium | Prioritize critical paths (auth, core pages) first. Use API contract testing to parallelize frontend and backend work. Leverage AI coding assistants to accelerate repetitive wiring. |
|
||||||
|
| R5 | **Team bandwidth constraints** | Medium | High | AI-assisted development (both developers use AI tools) effectively increases output by 1.5-2x. Strict scope management -- no feature creep. Clear sprint goals with daily standups. |
|
||||||
|
| R6 | **ELAI video generation latency for Humanisation** | Low | Medium | Pre-generate common tutoring videos. Cache frequently requested content. Set user expectations (1-3 min generation time). Consider alternative: pre-recorded avatar videos for standard content. |
|
||||||
|
| R7 | **Production infrastructure not ready on time** | Low | High | Request production server provisioning by August 1 (3 weeks before go-live). Test deployment procedure on staging first. Have cloud-based fallback plan. |
|
||||||
|
| R8 | **UTAS UAT testers unavailable or insufficient feedback** | Medium | Medium | Provide detailed test scenarios with step-by-step instructions. Offer guided UAT sessions. Establish minimum UAT participation requirements in the scope agreement. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Assumptions and Prerequisites
|
||||||
|
|
||||||
|
### 11.1 Assumptions
|
||||||
|
|
||||||
|
| # | Assumption | Impact if Invalid |
|
||||||
|
|---|-----------|-------------------|
|
||||||
|
| A1 | UTAS provides 3-5 staff and 10-20 students for UAT testing | Delays UAT; reduces quality assurance |
|
||||||
|
| A2 | OpenEduCat modules are compatible with Odoo 19 | Requires building custom LMS models; adds 3-4 weeks |
|
||||||
|
| A3 | UTAS SIS exposes REST APIs for enrollment, grades, and attendance | Requires alternative integration (CSV, database sync); adds 2-3 weeks |
|
||||||
|
| A4 | Math and IT course content is either provided by UTAS or generated by AI | If neither, course content creation becomes a separate workstream |
|
||||||
|
| A5 | The platform will be accessed via web browsers only (no mobile app) | Mobile app would be a separate project |
|
||||||
|
| A6 | Institutional licensing model (no per-student payment processing) | Payment gateway integration would add 2-3 weeks |
|
||||||
|
| A7 | Single UTAS deployment (not multi-university / multi-tenant initially) | Multi-tenant support would add architectural complexity |
|
||||||
|
| A8 | Internet connectivity at UTAS is sufficient for AI cloud services (OpenAI, ELAI, AWS Polly) | On-premise AI deployment would be a separate project |
|
||||||
|
|
||||||
|
### 11.2 Prerequisites (with Deadlines)
|
||||||
|
|
||||||
|
| # | Prerequisite | Owner | Deadline | Status |
|
||||||
|
|---|-------------|-------|----------|--------|
|
||||||
|
| P1 | Scope agreement signed | UTAS + EnCoach | March 31, 2026 | Pending |
|
||||||
|
| P2 | OpenEduCat modules provided | EnCoach / UTAS | May 15, 2026 | Pending |
|
||||||
|
| P3 | UTAS brand assets delivered | UTAS | June 15, 2026 | Pending |
|
||||||
|
| P4 | SIS API documentation provided | UTAS IT | June 15, 2026 | Pending |
|
||||||
|
| P5 | SIS test environment access granted | UTAS IT | July 1, 2026 | Pending |
|
||||||
|
| P6 | UAT test team identified | UTAS | May 1, 2026 | Pending |
|
||||||
|
| P7 | Production server provisioned | UTAS IT / EnCoach | August 1, 2026 | Pending |
|
||||||
|
| P8 | Math/IT course content available | UTAS SMEs | May 15, 2026 | Pending |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Module-to-API Mapping (Existing Backend Endpoints)
|
||||||
|
|
||||||
|
The following 70+ API endpoints are already built and deployed in the Odoo 19 backend. These form the foundation for Wave 1 frontend integration.
|
||||||
|
|
||||||
|
| Category | Endpoints | Count |
|
||||||
|
|----------|-----------|-------|
|
||||||
|
| Authentication | `/api/login`, `/api/logout`, `/api/reset`, `/api/reset/sendVerification` | 4 |
|
||||||
|
| Users | `/api/user`, `/api/users/update`, `/api/users/list`, `/api/users/<id>` | 4 |
|
||||||
|
| Entities & Roles | `/api/entities`, `/api/roles`, `/api/permissions` | 7 |
|
||||||
|
| Exams | `/api/exam`, `/api/exam/<module>`, `/api/exam/<module>/<id>`, `/api/exam/avatars` | 7 |
|
||||||
|
| AI Generation | `/api/exam/reading/*`, `/api/exam/listening/*`, `/api/exam/writing/*`, `/api/exam/speaking/*`, `/api/exam/level/generate` | 8 |
|
||||||
|
| Media | `/api/exam/listening/media`, `/api/exam/listening/transcribe`, `/api/exam/speaking/media`, `/api/transcribe` | 6 |
|
||||||
|
| Evaluations & Grading | `/api/evaluate/writing`, `/api/evaluate/speaking`, `/api/evaluate/interactiveSpeaking`, `/api/grading/multiple` | 6 |
|
||||||
|
| Classrooms | `/api/groups`, `/api/groups/<id>` | 3 |
|
||||||
|
| Assignments | `/api/assignments`, `/api/assignments/<id>`, `/api/assignments/<id>/start`, release, archive | 8 |
|
||||||
|
| Sessions & Stats | `/api/sessions`, `/api/stats`, `/api/statistical` | 5 |
|
||||||
|
| Training | `/api/training`, `/api/training/<id>`, `/api/training/tips`, `/api/training/walkthrough` | 4 |
|
||||||
|
| Subscriptions | `/api/packages`, `/api/stripe`, `/api/paypal`, `/api/paymob` + webhooks | 7 |
|
||||||
|
| Registration | `/api/register`, `/api/code/<code>`, `/api/batch_users`, `/api/make_user` | 4 |
|
||||||
|
| Tickets | `/api/tickets`, `/api/tickets/<id>` | 3 |
|
||||||
|
| Storage | `/api/storage`, `/api/storage/delete` | 2 |
|
||||||
|
| Approvals | `/api/approval-workflows`, `/api/approval-workflows/<id>` | 3 |
|
||||||
|
| **Total** | | **~81** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: AI Service Configuration Summary
|
||||||
|
|
||||||
|
| Service | Model | Key Config | Usage |
|
||||||
|
|---------|-------|-----------|-------|
|
||||||
|
| OpenAI GPT | gpt-4o (primary), gpt-3.5-turbo (secondary) | Temperature: 0.1 (grading), 0.7 (generation), 0.2 (summaries). Max tokens: 4,097. JSON response format. | Exam generation, grading, evaluation, training tips |
|
||||||
|
| Whisper | base (local, ~1GB) | 4 instances, round-robin, 16kHz mono, 30s chunks with overlap | Speaking transcription, audio-to-text |
|
||||||
|
| AWS Polly | Neural engine | 11 voices, MP3 output, 3,000 char chunks | Listening exam audio generation |
|
||||||
|
| ELAI | 7 avatars | ElevenLabs + Azure voices, fade_in animation | Speaking exam video, virtual tutoring |
|
||||||
|
| GPTZero | v2/predict/text | Per-sentence analysis, confidence scoring | Writing AI detection |
|
||||||
|
| FAISS | IndexFlatL2 + all-MiniLM-L6-v2 | Top-5 results, 7 category indices, 80 training tips | Personalized training recommendations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix C: Frontend Page Inventory (New React Application)
|
||||||
|
|
||||||
|
| Portal | Pages | Key Features |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| **Student** | Dashboard, Courses, Course Detail, Assignments, Grades, Attendance, Timetable, Profile | 8 pages |
|
||||||
|
| **Teacher** | Dashboard, Courses, Course Builder, Course Edit, Assignments, Assignment Detail, Attendance, Students, Timetable, Profile | 10 pages |
|
||||||
|
| **Admin (LMS)** | Dashboard, Courses, Students, Teachers, Batches, Batch Detail, Timetable, Reports, Settings, Profile | 10 pages |
|
||||||
|
| **Admin (Platform)** | Platform Dashboard, Assignments, Exams List, Exam Structures, Rubrics, Generation, Approval Workflows, Users, Entities, Classrooms, Student Performance, Stats Corporate, Record, Vocabulary, Grammar, Payment Record, Tickets, Settings, Exam | 19 pages |
|
||||||
|
| **Auth** | Login, Register, Forgot Password | 3 pages |
|
||||||
|
| **Total** | | **~50+ pages** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document prepared by EnCoach Engineering Team. For questions or clarifications, contact the Solution Architect.*
|
||||||
86
docs/UTAS_SYSTEM_SCOPE.md
Normal file
86
docs/UTAS_SYSTEM_SCOPE.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# EnCoach -- UTAS System Scope Definition
|
||||||
|
|
||||||
|
**Date:** March 16, 2026
|
||||||
|
**Project:** EnCoach University Deployment for UTAS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Modules
|
||||||
|
|
||||||
|
### 1. Learning Management System (LMS)
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Engine** | OpenEduCat (Odoo 19 native modules) |
|
||||||
|
| **Frontend** | React 18 SPA -- student, teacher, and admin portals (50+ pages built) |
|
||||||
|
| **Capabilities** | Course catalog and enrollment, batch/cohort management, timetable scheduling, attendance tracking, gradebook, academic reporting |
|
||||||
|
| **Integration** | REST API bridge between OpenEduCat models and React frontend |
|
||||||
|
| **Subjects** | English Self Learning, Mathematics, Information Technology (extensible) |
|
||||||
|
|
||||||
|
### 2. Exam Portal
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Exam Types** | Reading, Listening, Writing, Speaking, Level Test, Math, IT |
|
||||||
|
| **AI Generation** | GPT-4o generates exam content per subject and difficulty level |
|
||||||
|
| **AI Grading** | Automated grading for writing (GPT-4o + IELTS rubric), speaking (Whisper STT + GPT-4o), MCQ, short answer, numerical |
|
||||||
|
| **AI Detection** | GPTZero flags AI-generated student submissions |
|
||||||
|
| **Media** | AWS Polly TTS for listening audio, ELAI avatars for speaking video |
|
||||||
|
| **Workflow** | Exam creation, review, approval, assignment, timed sessions, grading, results |
|
||||||
|
| **Roles** | Admin creates/approves, teacher assigns, student takes, system grades |
|
||||||
|
|
||||||
|
### 3. Student Onboarding
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Registration** | Self-registration or bulk import (CSV/batch) by admin |
|
||||||
|
| **SIS Sync** | Automatic student provisioning from UTAS Student Information System |
|
||||||
|
| **Enrollment** | Assignment to courses, batches, and classrooms upon registration |
|
||||||
|
| **Verification** | Email verification, registration code validation |
|
||||||
|
| **Roles** | Student, teacher, admin -- role-based access from first login |
|
||||||
|
|
||||||
|
### 4. Knowledge Base
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Training Tips** | 80+ categorized tips (reading, writing, strategy, vocabulary, grammar) with FAISS semantic search |
|
||||||
|
| **Personalization** | After each exam, GPT analyzes performance and retrieves relevant tips via RAG (Retrieval-Augmented Generation) |
|
||||||
|
| **Walkthroughs** | Per-module guided learning paths |
|
||||||
|
| **AI Tutoring** | ELAI avatar virtual tutors provide video-based explanations and guidance (Humanisation) |
|
||||||
|
| **Content Scope** | English/IELTS (existing), Math and IT (to be developed) |
|
||||||
|
|
||||||
|
### 5. Helpdesk System
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Ticketing** | Students and staff create support tickets with category, priority, and description |
|
||||||
|
| **Workflow** | Creation, assignment, status tracking (open, in-progress, resolved, closed) |
|
||||||
|
| **Roles** | Students/teachers submit; admin staff categorize, assign, and resolve |
|
||||||
|
| **Backend** | `encoach_ticket` Odoo module with full CRUD API (`/api/tickets`) |
|
||||||
|
|
||||||
|
### 6. API Integration with SIS
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Direction** | Bidirectional: UTAS SIS <--> EnCoach |
|
||||||
|
| **Inbound (SIS to EnCoach)** | Student enrollment data, course catalog mapping, student status changes |
|
||||||
|
| **Outbound (EnCoach to SIS)** | Exam grades, attendance records, course completion status |
|
||||||
|
| **Method** | REST API (preferred) or CSV import/export (fallback) |
|
||||||
|
| **Frequency** | Real-time webhook (if available) or scheduled sync (daily batch) |
|
||||||
|
| **Prerequisite** | UTAS provides SIS API documentation, test environment, and technical contact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack Summary
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui |
|
||||||
|
| Backend | Odoo 19, Python 3.11, PostgreSQL 16 |
|
||||||
|
| LMS | OpenEduCat (Odoo modules) |
|
||||||
|
| AI | OpenAI GPT-4o, Whisper, AWS Polly, ELAI, GPTZero, FAISS |
|
||||||
|
| Deployment | Docker Compose, Nginx |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*EnCoach Engineering -- UTAS Deployment*
|
||||||
26
eslint.config.js
Normal file
26
eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
20
index.html
Normal file
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EnCoach — IELTS & English Learning Platform</title>
|
||||||
|
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
|
||||||
|
<meta name="author" content="EnCoach" />
|
||||||
|
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
|
||||||
|
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8215
package-lock.json
generated
Normal file
8215
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
89
package.json
Normal file
89
package.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "vite_react_shadcn_ts",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"build:dev": "vite build --mode development",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.11",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.15",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.15",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slider": "^1.3.5",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tanstack/react-query": "^5.83.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.462.0",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.61.1",
|
||||||
|
"react-resizable-panels": "^2.1.9",
|
||||||
|
"react-router-dom": "^6.30.1",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^0.9.9",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.0",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@types/node": "^22.16.5",
|
||||||
|
"@types/react": "^18.3.23",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.32.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
|
"lovable-tagger": "^1.1.13",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.38.0",
|
||||||
|
"vite": "^5.4.19",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
1
public/placeholder.svg
Normal file
1
public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
14
public/robots.txt
Normal file
14
public/robots.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
182
src/App.tsx
Normal file
182
src/App.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { AuthProvider } from "@/contexts/AuthContext";
|
||||||
|
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||||
|
import StudentLayout from "@/components/StudentLayout";
|
||||||
|
import TeacherLayout from "@/components/TeacherLayout";
|
||||||
|
import AdminLmsLayout from "@/components/AdminLmsLayout";
|
||||||
|
import Login from "@/pages/Login";
|
||||||
|
import Register from "@/pages/Register";
|
||||||
|
import ForgotPassword from "@/pages/ForgotPassword";
|
||||||
|
// Original platform pages
|
||||||
|
import AdminDashboard from "@/pages/AdminDashboard";
|
||||||
|
import UsersPage from "@/pages/UsersPage";
|
||||||
|
import EntitiesPage from "@/pages/EntitiesPage";
|
||||||
|
import AssignmentsPage from "@/pages/AssignmentsPage";
|
||||||
|
import ExamsListPage from "@/pages/ExamsListPage";
|
||||||
|
import ExamStructuresPage from "@/pages/ExamStructuresPage";
|
||||||
|
import RubricsPage from "@/pages/RubricsPage";
|
||||||
|
import GenerationPage from "@/pages/GenerationPage";
|
||||||
|
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
|
||||||
|
import ClassroomsPage from "@/pages/ClassroomsPage";
|
||||||
|
import StudentPerformancePage from "@/pages/StudentPerformancePage";
|
||||||
|
import StatsCorporatePage from "@/pages/StatsCorporatePage";
|
||||||
|
import RecordPage from "@/pages/RecordPage";
|
||||||
|
import VocabularyPage from "@/pages/VocabularyPage";
|
||||||
|
import GrammarPage from "@/pages/GrammarPage";
|
||||||
|
import PaymentRecordPage from "@/pages/PaymentRecordPage";
|
||||||
|
import TicketsPage from "@/pages/TicketsPage";
|
||||||
|
import SettingsPage from "@/pages/SettingsPage";
|
||||||
|
import ProfilePage from "@/pages/ProfilePage";
|
||||||
|
import ExamPage from "@/pages/ExamPage";
|
||||||
|
// Student pages
|
||||||
|
import StudentDashboard from "@/pages/student/StudentDashboard";
|
||||||
|
import StudentCourses from "@/pages/student/StudentCourses";
|
||||||
|
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
|
||||||
|
import StudentAssignments from "@/pages/student/StudentAssignments";
|
||||||
|
import StudentGrades from "@/pages/student/StudentGrades";
|
||||||
|
import StudentAttendance from "@/pages/student/StudentAttendance";
|
||||||
|
import StudentTimetable from "@/pages/student/StudentTimetable";
|
||||||
|
import StudentProfile from "@/pages/student/StudentProfile";
|
||||||
|
// Adaptive learning pages
|
||||||
|
import SubjectSelection from "@/pages/student/SubjectSelection";
|
||||||
|
import DiagnosticTest from "@/pages/student/DiagnosticTest";
|
||||||
|
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
|
||||||
|
import LearningPlanPage from "@/pages/student/LearningPlan";
|
||||||
|
import TopicLearning from "@/pages/student/TopicLearning";
|
||||||
|
// Teacher pages
|
||||||
|
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
|
||||||
|
import TeacherCourses from "@/pages/teacher/TeacherCourses";
|
||||||
|
import CourseBuilder from "@/pages/teacher/CourseBuilder";
|
||||||
|
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
|
||||||
|
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
|
||||||
|
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
|
||||||
|
import TeacherStudents from "@/pages/teacher/TeacherStudents";
|
||||||
|
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
|
||||||
|
import TeacherProfile from "@/pages/teacher/TeacherProfile";
|
||||||
|
// Admin LMS pages
|
||||||
|
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
|
||||||
|
import AdminCourses from "@/pages/admin/AdminCourses";
|
||||||
|
import AdminStudents from "@/pages/admin/AdminStudents";
|
||||||
|
import AdminTeachers from "@/pages/admin/AdminTeachers";
|
||||||
|
import AdminBatches from "@/pages/admin/AdminBatches";
|
||||||
|
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
|
||||||
|
import AdminTimetable from "@/pages/admin/AdminTimetable";
|
||||||
|
import AdminReports from "@/pages/admin/AdminReports";
|
||||||
|
import AdminSettings from "@/pages/admin/AdminSettings";
|
||||||
|
import AdminProfileLms from "@/pages/admin/AdminProfile";
|
||||||
|
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
|
||||||
|
import ResourceManager from "@/pages/admin/ResourceManager";
|
||||||
|
import NotFound from "@/pages/NotFound";
|
||||||
|
import { queryClient } from "@/lib/query-client";
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<Sonner />
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
{/* Auth routes */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
|
|
||||||
|
{/* Student routes */}
|
||||||
|
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
|
||||||
|
<Route element={<StudentLayout />}>
|
||||||
|
<Route path="/student/dashboard" element={<StudentDashboard />} />
|
||||||
|
<Route path="/student/courses" element={<StudentCourses />} />
|
||||||
|
<Route path="/student/courses/:id" element={<StudentCourseDetail />} />
|
||||||
|
<Route path="/student/assignments" element={<StudentAssignments />} />
|
||||||
|
<Route path="/student/grades" element={<StudentGrades />} />
|
||||||
|
<Route path="/student/attendance" element={<StudentAttendance />} />
|
||||||
|
<Route path="/student/timetable" element={<StudentTimetable />} />
|
||||||
|
<Route path="/student/profile" element={<StudentProfile />} />
|
||||||
|
<Route path="/student/subjects" element={<SubjectSelection />} />
|
||||||
|
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
|
||||||
|
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
|
||||||
|
<Route path="/student/plan/:subjectId" element={<LearningPlanPage />} />
|
||||||
|
<Route path="/student/topic/:topicId" element={<TopicLearning />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Teacher routes */}
|
||||||
|
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
|
||||||
|
<Route element={<TeacherLayout />}>
|
||||||
|
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
|
||||||
|
<Route path="/teacher/courses" element={<TeacherCourses />} />
|
||||||
|
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
|
||||||
|
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
|
||||||
|
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
|
||||||
|
<Route path="/teacher/assignments/:id" element={<TeacherAssignmentDetail />} />
|
||||||
|
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
|
||||||
|
<Route path="/teacher/students" element={<TeacherStudents />} />
|
||||||
|
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
|
||||||
|
<Route path="/teacher/profile" element={<TeacherProfile />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin routes — unified: original platform + LMS + AI */}
|
||||||
|
<Route element={<ProtectedRoute allowedRoles={["admin", "corporate", "mastercorporate", "agent", "developer"]} />}>
|
||||||
|
<Route element={<AdminLmsLayout />}>
|
||||||
|
{/* LMS Dashboard */}
|
||||||
|
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
|
||||||
|
{/* Original platform dashboard */}
|
||||||
|
<Route path="/admin/platform" element={<AdminDashboard />} />
|
||||||
|
{/* LMS pages */}
|
||||||
|
<Route path="/admin/courses" element={<AdminCourses />} />
|
||||||
|
<Route path="/admin/students" element={<AdminStudents />} />
|
||||||
|
<Route path="/admin/teachers" element={<AdminTeachers />} />
|
||||||
|
<Route path="/admin/batches" element={<AdminBatches />} />
|
||||||
|
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
|
||||||
|
<Route path="/admin/timetable" element={<AdminTimetable />} />
|
||||||
|
<Route path="/admin/reports" element={<AdminReports />} />
|
||||||
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
|
<Route path="/admin/profile" element={<AdminProfileLms />} />
|
||||||
|
{/* Original academic pages */}
|
||||||
|
<Route path="/admin/assignments" element={<AssignmentsPage />} />
|
||||||
|
<Route path="/admin/examsList" element={<ExamsListPage />} />
|
||||||
|
<Route path="/admin/exam-structures" element={<ExamStructuresPage />} />
|
||||||
|
<Route path="/admin/rubrics" element={<RubricsPage />} />
|
||||||
|
<Route path="/admin/generation" element={<GenerationPage />} />
|
||||||
|
<Route path="/admin/approval-workflows" element={<ApprovalWorkflowsPage />} />
|
||||||
|
{/* Original management pages */}
|
||||||
|
<Route path="/admin/users" element={<UsersPage />} />
|
||||||
|
<Route path="/admin/entities" element={<EntitiesPage />} />
|
||||||
|
<Route path="/admin/classrooms" element={<ClassroomsPage />} />
|
||||||
|
{/* Original report pages */}
|
||||||
|
<Route path="/admin/student-performance" element={<StudentPerformancePage />} />
|
||||||
|
<Route path="/admin/stats-corporate" element={<StatsCorporatePage />} />
|
||||||
|
<Route path="/admin/record" element={<RecordPage />} />
|
||||||
|
{/* Training */}
|
||||||
|
<Route path="/admin/training/vocabulary" element={<VocabularyPage />} />
|
||||||
|
<Route path="/admin/training/grammar" element={<GrammarPage />} />
|
||||||
|
{/* Support */}
|
||||||
|
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
|
||||||
|
<Route path="/admin/tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="/admin/settings-platform" element={<SettingsPage />} />
|
||||||
|
<Route path="/admin/exam" element={<ExamPage />} />
|
||||||
|
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
|
||||||
|
<Route path="/admin/resources" element={<ResourceManager />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Redirects */}
|
||||||
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
|
{/* Legacy route redirects */}
|
||||||
|
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TooltipProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
313
src/components/AdminLmsLayout.tsx
Normal file
313
src/components/AdminLmsLayout.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
|
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
|
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { NavLink } from "@/components/NavLink";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||||
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
|
||||||
|
BreadcrumbPage, BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import {
|
||||||
|
GraduationCap, LogOut, User, Settings, LayoutDashboard, ClipboardList,
|
||||||
|
FileText, Layers, BookOpen, Wand2, GitBranch, Users, BarChart3,
|
||||||
|
Building2, History, CreditCard, Ticket, BookA, PenTool,
|
||||||
|
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// ============= Navigation Config =============
|
||||||
|
interface NavItem { title: string; url: string; icon: LucideIcon }
|
||||||
|
|
||||||
|
const overviewItems: NavItem[] = [
|
||||||
|
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||||
|
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const lmsItems: NavItem[] = [
|
||||||
|
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
||||||
|
{ title: "Students", url: "/admin/students", icon: Users },
|
||||||
|
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||||
|
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
||||||
|
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
||||||
|
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const academicItems: NavItem[] = [
|
||||||
|
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||||
|
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
||||||
|
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
||||||
|
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||||
|
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
||||||
|
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adaptiveItems: NavItem[] = [
|
||||||
|
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||||
|
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
||||||
|
];
|
||||||
|
|
||||||
|
const managementItems: NavItem[] = [
|
||||||
|
{ title: "Users", url: "/admin/users", icon: Users },
|
||||||
|
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
||||||
|
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reportItems: NavItem[] = [
|
||||||
|
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
||||||
|
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||||
|
{ title: "Record", url: "/admin/record", icon: History },
|
||||||
|
];
|
||||||
|
|
||||||
|
const trainingItems: NavItem[] = [
|
||||||
|
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||||
|
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||||
|
];
|
||||||
|
|
||||||
|
const supportItems: NavItem[] = [
|
||||||
|
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
||||||
|
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
||||||
|
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============= Reusable sidebar group =============
|
||||||
|
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.url}>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.title}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Breadcrumbs =============
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
||||||
|
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
||||||
|
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
||||||
|
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
||||||
|
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
||||||
|
classrooms: "Classrooms", "student-performance": "Student Performance",
|
||||||
|
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
||||||
|
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
||||||
|
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
||||||
|
profile: "Profile", exam: "Exam",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AppBreadcrumbs() {
|
||||||
|
const location = useLocation();
|
||||||
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const path = "/" + segments.slice(0, i + 1).join("/");
|
||||||
|
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||||
|
const isLast = i === segments.length - 1;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={path}>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{isLast ? <BreadcrumbPage>{label}</BreadcrumbPage> : <BreadcrumbLink asChild><Link to={path}>{label}</Link></BreadcrumbLink>}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Main Layout =============
|
||||||
|
export default function AdminLmsLayout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="min-h-screen flex w-full">
|
||||||
|
<AdminSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<AppBreadcrumbs />
|
||||||
|
</div>
|
||||||
|
<AiSearchBar />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NotificationDropdown />
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Ticket className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<span className="text-xs font-semibold text-primary-foreground">{initials}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
||||||
|
<User className="mr-2 h-4 w-4" /> Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AiAssistantDrawer />
|
||||||
|
<Link
|
||||||
|
to="/admin/tickets"
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Need help?</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Admin Sidebar =============
|
||||||
|
function AdminSidebar() {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { hasAnyPermission, isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||||
|
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||||
|
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">EnCoach</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarNavGroup label="Overview" items={overviewItems} />
|
||||||
|
<SidebarNavGroup label="LMS" items={lmsItems} />
|
||||||
|
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
||||||
|
<SidebarNavGroup label="Academic" items={academicItems} />
|
||||||
|
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
||||||
|
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
|
<SidebarGroupLabel asChild>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||||
|
Training
|
||||||
|
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{trainingItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.url}>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.title}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<SidebarFooter className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-semibold text-primary">{user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"}</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/AppLayout.tsx
Normal file
145
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
|
||||||
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import { AppSidebar } from "@/components/AppSidebar";
|
||||||
|
import {
|
||||||
|
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
|
||||||
|
BreadcrumbPage, BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
|
||||||
|
import AiSearchBar from "@/components/ai/AiSearchBar";
|
||||||
|
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"admin": "Admin",
|
||||||
|
"assignments": "Assignments",
|
||||||
|
"examsList": "Exams List",
|
||||||
|
"exam-structures": "Exam Structures",
|
||||||
|
"rubrics": "Rubrics",
|
||||||
|
"generation": "Exam Generation",
|
||||||
|
"approval-workflows": "Approval Workflows",
|
||||||
|
"users": "Users",
|
||||||
|
"entities": "Entities",
|
||||||
|
"classrooms": "Classrooms",
|
||||||
|
"student-performance": "Student Performance",
|
||||||
|
"stats-corporate": "Stats Corporate",
|
||||||
|
"record": "Record",
|
||||||
|
"training": "Training",
|
||||||
|
"vocabulary": "Vocabulary",
|
||||||
|
"grammar": "Grammar",
|
||||||
|
"payment-record": "Payment Record",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"settings": "Settings",
|
||||||
|
"profile": "Profile",
|
||||||
|
"exam": "Exam",
|
||||||
|
"tutor": "Tutor",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AppBreadcrumbs() {
|
||||||
|
const location = useLocation();
|
||||||
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to="/dashboard/admin">Home</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{segments.map((seg, i) => {
|
||||||
|
const path = "/" + segments.slice(0, i + 1).join("/");
|
||||||
|
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
|
||||||
|
const isLast = i === segments.length - 1;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={path}>
|
||||||
|
<BreadcrumbSeparator />
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{isLast ? (
|
||||||
|
<BreadcrumbPage>{label}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink asChild>
|
||||||
|
<Link to={path}>{label}</Link>
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="min-h-screen flex w-full">
|
||||||
|
<AppSidebar />
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<AppBreadcrumbs />
|
||||||
|
</div>
|
||||||
|
<AiSearchBar />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Ticket className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate("/settings")} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<span className="text-xs font-semibold text-primary-foreground">AU</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium">Admin User</p>
|
||||||
|
<p className="text-xs text-muted-foreground">admin@encoach.com</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/profile")}>
|
||||||
|
<User className="mr-2 h-4 w-4" /> Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/settings")}>
|
||||||
|
<Settings className="mr-2 h-4 w-4" /> Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/login")}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AiAssistantDrawer />
|
||||||
|
{/* Floating help button */}
|
||||||
|
<Link
|
||||||
|
to="/tickets"
|
||||||
|
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Need help?</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/AppSidebar.tsx
Normal file
158
src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard, ClipboardList, FileText, Layers, BookOpen, Wand2,
|
||||||
|
GitBranch, Users, BarChart3, Building2, History, GraduationCap,
|
||||||
|
CreditCard, Ticket, Settings, User, BookA, PenTool, LogOut
|
||||||
|
} from "lucide-react";
|
||||||
|
import { NavLink } from "@/components/NavLink";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
|
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
|
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
const mainItems = [
|
||||||
|
{ title: "Dashboard", url: "/dashboard/admin", icon: LayoutDashboard },
|
||||||
|
{ title: "Assignments", url: "/assignments", icon: ClipboardList },
|
||||||
|
{ title: "Exams List", url: "/examsList", icon: FileText },
|
||||||
|
{ title: "Exam Structures", url: "/exam-structures", icon: Layers },
|
||||||
|
{ title: "Rubrics", url: "/rubrics", icon: BookOpen },
|
||||||
|
{ title: "Generation", url: "/generation", icon: Wand2 },
|
||||||
|
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
|
||||||
|
];
|
||||||
|
|
||||||
|
const managementItems = [
|
||||||
|
{ title: "Users", url: "/users", icon: Users },
|
||||||
|
{ title: "Entities", url: "/entities", icon: Building2 },
|
||||||
|
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
|
||||||
|
];
|
||||||
|
|
||||||
|
const reportItems = [
|
||||||
|
{ title: "Student Performance", url: "/student-performance", icon: BarChart3 },
|
||||||
|
{ title: "Stats Corporate", url: "/stats-corporate", icon: Building2 },
|
||||||
|
{ title: "Record", url: "/record", icon: History },
|
||||||
|
];
|
||||||
|
|
||||||
|
const trainingItems = [
|
||||||
|
{ title: "Vocabulary", url: "/training/vocabulary", icon: BookA },
|
||||||
|
{ title: "Grammar", url: "/training/grammar", icon: PenTool },
|
||||||
|
];
|
||||||
|
|
||||||
|
const supportItems = [
|
||||||
|
{ title: "Payment Record", url: "/payment-record", icon: CreditCard },
|
||||||
|
{ title: "Tickets", url: "/tickets", icon: Ticket },
|
||||||
|
{ title: "Settings", url: "/settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SidebarNavGroup({ label, items }: { label: string; items: typeof mainItems }) {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
end={item.url === "/dashboard/admin"}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.title}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||||
|
EnCoach
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarSeparator />
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarNavGroup label="Academic" items={mainItems} />
|
||||||
|
<SidebarNavGroup label="Management" items={managementItems} />
|
||||||
|
<SidebarNavGroup label="Reports" items={reportItems} />
|
||||||
|
|
||||||
|
<SidebarGroup>
|
||||||
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
|
<SidebarGroupLabel asChild>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||||
|
Training
|
||||||
|
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{trainingItems.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.title}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<SidebarNavGroup label="Support" items={supportItems} />
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarSeparator />
|
||||||
|
|
||||||
|
<SidebarFooter className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<User className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="text-sm font-medium truncate text-sidebar-foreground">Admin User</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">admin@encoach.com</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/NavLink.tsx
Normal file
28
src/components/NavLink.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
|
||||||
|
import { forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
|
||||||
|
className?: string;
|
||||||
|
activeClassName?: string;
|
||||||
|
pendingClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
|
||||||
|
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RouterNavLink
|
||||||
|
ref={ref}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive, isPending }) =>
|
||||||
|
cn(className, isActive && activeClassName, isPending && pendingClassName)
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
NavLink.displayName = "NavLink";
|
||||||
|
|
||||||
|
export { NavLink };
|
||||||
36
src/components/NotificationDropdown.tsx
Normal file
36
src/components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
export default function NotificationDropdown() {
|
||||||
|
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||||
|
const unread = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<div className="px-3 py-2 font-semibold text-sm">Notifications</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{notifications.slice(0, 4).map((n) => (
|
||||||
|
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||||
|
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/ProtectedRoute.tsx
Normal file
64
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { usePermissions } from "@/hooks/usePermissions";
|
||||||
|
import type { UserRole } from "@/types/auth";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allowedRoles?: UserRole[];
|
||||||
|
requiredPermissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleDashboard(role: UserRole): string {
|
||||||
|
switch (role) {
|
||||||
|
case "student":
|
||||||
|
return "/student/dashboard";
|
||||||
|
case "teacher":
|
||||||
|
return "/teacher/dashboard";
|
||||||
|
case "admin":
|
||||||
|
case "developer":
|
||||||
|
return "/admin/dashboard";
|
||||||
|
case "corporate":
|
||||||
|
case "mastercorporate":
|
||||||
|
case "agent":
|
||||||
|
return "/admin/platform";
|
||||||
|
default:
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ allowedRoles, requiredPermissions }: Props) {
|
||||||
|
const { user, isAuthenticated, isLoading } = useAuth();
|
||||||
|
const { hasAllPermissions, isLoading: permissionsLoading } = usePermissions();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles && !allowedRoles.includes(user.user_type)) {
|
||||||
|
return <Navigate to={getRoleDashboard(user.user_type)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||||
|
if (permissionsLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAllPermissions(requiredPermissions)) {
|
||||||
|
return <Navigate to={getRoleDashboard(user.user_type)} replace />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
152
src/components/RoleLayout.tsx
Normal file
152
src/components/RoleLayout.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
|
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
|
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { NavLink } from "@/components/NavLink";
|
||||||
|
import { useAuth, UserRole } from "@/contexts/AuthContext";
|
||||||
|
import NotificationDropdown from "@/components/NotificationDropdown";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { GraduationCap, LogOut, User, Settings, LucideIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavGroup {
|
||||||
|
label: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleLayoutProps {
|
||||||
|
navGroups: NavGroup[];
|
||||||
|
role: UserRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||||
|
const { state } = useSidebar();
|
||||||
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{navGroups.map((group) => (
|
||||||
|
<SidebarGroup key={group.label}>
|
||||||
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.url}>
|
||||||
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
|
<NavLink
|
||||||
|
to={item.url}
|
||||||
|
end={item.url.endsWith("/dashboard")}
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && <span>{item.title}</span>}
|
||||||
|
</NavLink>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="min-h-screen flex w-full">
|
||||||
|
<Sidebar collapsible="icon">
|
||||||
|
<SidebarHeader className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
|
||||||
|
EnCoach
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarNav navGroups={navGroups} />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<SidebarFooter className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SidebarTrigger />
|
||||||
|
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NotificationDropdown />
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<span className="text-xs font-semibold text-primary-foreground">{initials}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium">{user?.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
||||||
|
<User className="mr-2 h-4 w-4" /> Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" /> Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/StudentLayout.tsx
Normal file
40
src/components/StudentLayout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
|
import {
|
||||||
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
|
CalendarCheck, Calendar, User, Target, GraduationCap,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Learning",
|
||||||
|
items: [
|
||||||
|
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
|
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
||||||
|
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Adaptive Learning",
|
||||||
|
items: [
|
||||||
|
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Progress",
|
||||||
|
items: [
|
||||||
|
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
||||||
|
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||||
|
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Account",
|
||||||
|
items: [
|
||||||
|
{ title: "Profile", url: "/student/profile", icon: User },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StudentLayout() {
|
||||||
|
return <RoleLayout navGroups={navGroups} role="student" />;
|
||||||
|
}
|
||||||
34
src/components/TeacherLayout.tsx
Normal file
34
src/components/TeacherLayout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
|
import {
|
||||||
|
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
||||||
|
CalendarCheck, Users, Calendar, User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: "Teaching",
|
||||||
|
items: [
|
||||||
|
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||||
|
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
||||||
|
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Management",
|
||||||
|
items: [
|
||||||
|
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||||
|
{ title: "Students", url: "/teacher/students", icon: Users },
|
||||||
|
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Account",
|
||||||
|
items: [
|
||||||
|
{ title: "Profile", url: "/teacher/profile", icon: User },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TeacherLayout() {
|
||||||
|
return <RoleLayout navGroups={navGroups} role="teacher" />;
|
||||||
|
}
|
||||||
80
src/components/ai/AiAlertBanner.tsx
Normal file
80
src/components/ai/AiAlertBanner.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
|
||||||
|
export default function AiAlertBanner() {
|
||||||
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||||
|
|
||||||
|
const { data: alerts, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["ai", "alerts"],
|
||||||
|
queryFn: () => analyticsService.getAlerts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && !errorDismissed) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError && errorDismissed) return null;
|
||||||
|
|
||||||
|
if (!alerts?.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||||
|
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visible.map((alert) => (
|
||||||
|
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/ai/AiAssistantDrawer.tsx
Normal file
139
src/components/ai/AiAssistantDrawer.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Sparkles, Send, Loader2 } from "lucide-react";
|
||||||
|
import { coachingService } from "@/services/coaching.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
"Platform health summary",
|
||||||
|
"Show dropout risks",
|
||||||
|
"Compare course performance",
|
||||||
|
"Suggest batch improvements",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AiAssistantDrawer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const location = useLocation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const chatMutation = useMutation({
|
||||||
|
mutationFn: (message: string) =>
|
||||||
|
coachingService.chat({ message, context: { page: location.pathname } }),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setMessages((prev) => [...prev, { role: "ai", text: data.message }]);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Could not get a reply",
|
||||||
|
description: err.message || "Something went wrong. Try again.",
|
||||||
|
});
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "ai",
|
||||||
|
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSend = (text: string) => {
|
||||||
|
if (!text.trim() || chatMutation.isPending) return;
|
||||||
|
const userMsg = text.trim();
|
||||||
|
setMessages((prev) => [...prev, { role: "user", text: userMsg }]);
|
||||||
|
setInput("");
|
||||||
|
chatMutation.mutate(userMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
|
aria-label="AI Assistant"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetContent className="w-[400px] sm:w-[440px] flex flex-col">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
EnCoach AI Assistant
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-4">
|
||||||
|
{quickActions.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={chatMutation.isPending}
|
||||||
|
onClick={() => handleSend(action)}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto mt-4 space-y-3 min-h-0">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center text-muted-foreground text-sm py-8">
|
||||||
|
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||||
|
<p>Ask me anything about the platform,</p>
|
||||||
|
<p>or click a quick action above.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`rounded-lg p-3 text-sm ${
|
||||||
|
msg.role === "user"
|
||||||
|
? "bg-primary text-primary-foreground ml-8"
|
||||||
|
: "bg-muted mr-8"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.role === "ai" && (
|
||||||
|
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
||||||
|
)}
|
||||||
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{chatMutation.isPending && (
|
||||||
|
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
<span className="text-muted-foreground">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||||
|
<Input
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleSend(input)}
|
||||||
|
disabled={!input.trim() || chatMutation.isPending}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/ai/AiBatchOptimizer.tsx
Normal file
101
src/components/ai/AiBatchOptimizer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
batchId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiBatchOptimizer({ batchId }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (id: number) => analyticsService.getBatchOptimization(id),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
title: "Optimization failed",
|
||||||
|
description: err.message || "Could not analyze this batch.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (batchId == null) {
|
||||||
|
toast({
|
||||||
|
title: "No batch selected",
|
||||||
|
description: "Choose a batch to analyze, or open this from a batch detail page.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutation.reset();
|
||||||
|
setOpen(true);
|
||||||
|
mutation.mutate(batchId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (next: boolean) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) mutation.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = mutation.data ?? [];
|
||||||
|
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
|
||||||
|
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleOpen}>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Suggest Batch Split
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Batch Optimization
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing batch data...
|
||||||
|
</div>
|
||||||
|
) : mutation.isError ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
|
||||||
|
) : showResults ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<div key={i} className="rounded-lg bg-muted/30 p-4 border border-border/60">
|
||||||
|
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{s.impact} impact</p>
|
||||||
|
<p className="text-sm font-medium">{s.suggestion}</p>
|
||||||
|
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" onClick={handleApply}>
|
||||||
|
Apply Suggestion
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : showEmpty ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">No optimization suggestions for this batch.</p>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/ai/AiCreationAssistant.tsx
Normal file
215
src/components/ai/AiCreationAssistant.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
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 { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { lmsService } from "@/services/lms.service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: "course" | "assignment" | "batch" | "rubric" | "exam" | "student" | "teacher";
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
onGenerated?: (data: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
course: "Course", assignment: "Assignment", batch: "Batch", rubric: "Rubric",
|
||||||
|
exam: "Exam", student: "Student Plan", teacher: "Teacher Assignment",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AiCreationAssistant({ type, trigger, onGenerated }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [level, setLevel] = useState("b2");
|
||||||
|
const [subjectId, setSubjectId] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const generateCourseMutation = useMutation({
|
||||||
|
mutationFn: (payload: { title: string; subject_id?: number; level?: string }) =>
|
||||||
|
lmsService.aiGenerateCourse(payload),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Generation failed",
|
||||||
|
description: err.message || "Could not generate course outline.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (type !== "course") {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Not available",
|
||||||
|
description: "Live AI generation is wired for courses only. Use the course type to generate an outline.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const title = prompt.trim() || "Untitled course";
|
||||||
|
const sid = subjectId.trim() ? Number(subjectId) : undefined;
|
||||||
|
generateCourseMutation.mutate({
|
||||||
|
title,
|
||||||
|
...(sid !== undefined && !Number.isNaN(sid) ? { subject_id: sid } : {}),
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
if (!generateCourseMutation.data) return;
|
||||||
|
onGenerated?.(generateCourseMutation.data);
|
||||||
|
toast({
|
||||||
|
title: `AI ${typeLabels[type]} Applied`,
|
||||||
|
description: `The AI-generated ${type} has been applied to your form.`,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
generateCourseMutation.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger ? (
|
||||||
|
<div onClick={() => setOpen(true)}>{trigger}</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Generate {typeLabels[type]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) generateCourseMutation.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI {typeLabels[type]} Generator
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{type === "course" && generateCourseMutation.isSuccess ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-muted/30 p-4 space-y-3">
|
||||||
|
<p className="text-xs font-semibold text-primary flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> AI Generated {typeLabels[type]}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">Outline</span>
|
||||||
|
<pre className="text-sm mt-1 whitespace-pre-wrap break-words rounded-md border bg-background p-3 max-h-[40vh] overflow-y-auto">
|
||||||
|
{typeof generateCourseMutation.data?.outline === "string"
|
||||||
|
? generateCourseMutation.data.outline
|
||||||
|
: JSON.stringify(generateCourseMutation.data?.outline, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1" onClick={handleApply}>
|
||||||
|
Apply to Form
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => generateCourseMutation.reset()}>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Describe what you need</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder={`Describe the ${type} you want to create... (e.g. "An intermediate IELTS writing course focusing on Task 2 essays")`}
|
||||||
|
rows={3}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{type === "course" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Level</Label>
|
||||||
|
<Select value={level} onValueChange={setLevel}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
|
||||||
|
<SelectItem key={l} value={l.toLowerCase()}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subject ID (optional)</Label>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="e.g. 12"
|
||||||
|
value={subjectId}
|
||||||
|
onChange={(e) => setSubjectId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(type === "assignment" || type === "exam") && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Level</Label>
|
||||||
|
<Select defaultValue="b2">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
|
||||||
|
<SelectItem key={l} value={l.toLowerCase()}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Focus Area</Label>
|
||||||
|
<Select defaultValue="general">
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="general">General</SelectItem>
|
||||||
|
<SelectItem value="reading">Reading</SelectItem>
|
||||||
|
<SelectItem value="writing">Writing</SelectItem>
|
||||||
|
<SelectItem value="speaking">Speaking</SelectItem>
|
||||||
|
<SelectItem value="listening">Listening</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generateCourseMutation.isPending}
|
||||||
|
>
|
||||||
|
{generateCourseMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" /> Generate with AI
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/ai/AiGeneratorModal.tsx
Normal file
207
src/components/ai/AiGeneratorModal.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Sparkles, Loader2, Trash2 } from "lucide-react";
|
||||||
|
import type { ExamModule } from "@/types";
|
||||||
|
import { generationService } from "@/services/generation.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type ExerciseRow = { title?: string; description?: string; marks?: number };
|
||||||
|
|
||||||
|
function exerciseLabel(ex: unknown, index: number): ExerciseRow {
|
||||||
|
if (ex && typeof ex === "object") {
|
||||||
|
const o = ex as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
title: typeof o.title === "string" ? o.title : `Exercise ${index + 1}`,
|
||||||
|
description: typeof o.description === "string" ? o.description : undefined,
|
||||||
|
marks: typeof o.marks === "number" ? o.marks : typeof o.marks === "string" ? Number(o.marks) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { title: String(ex) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiGeneratorModal() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [moduleType, setModuleType] = useState<ExamModule>("reading");
|
||||||
|
const [difficulty, setDifficulty] = useState("b2");
|
||||||
|
const [count, setCount] = useState(5);
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [localExercises, setLocalExercises] = useState<unknown[] | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const generateMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
generationService.generate(moduleType, {
|
||||||
|
title: topic.trim() || `${moduleType} practice set`,
|
||||||
|
difficulty,
|
||||||
|
count,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Generation failed",
|
||||||
|
description: err.message || "Could not generate exercises.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
setLocalExercises(null);
|
||||||
|
generateMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generated = localExercises;
|
||||||
|
|
||||||
|
const handleRemove = (index: number) => {
|
||||||
|
setLocalExercises((prev) => (prev ? prev.filter((_, i) => i !== index) : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) {
|
||||||
|
setLocalExercises(null);
|
||||||
|
generateMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> Generate with AI
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" /> AI Assignment Generator
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!generated ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Module Type</Label>
|
||||||
|
<Select
|
||||||
|
value={moduleType}
|
||||||
|
onValueChange={(v) => setModuleType(v as ExamModule)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="reading">Reading</SelectItem>
|
||||||
|
<SelectItem value="listening">Listening</SelectItem>
|
||||||
|
<SelectItem value="writing">Writing</SelectItem>
|
||||||
|
<SelectItem value="speaking">Speaking</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Difficulty</Label>
|
||||||
|
<Select value={difficulty} onValueChange={setDifficulty}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
|
||||||
|
<SelectItem key={l} value={l.toLowerCase()}>
|
||||||
|
{l}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Count</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={count}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
onChange={(e) => setCount(Number(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Topic / Focus Area</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Academic reading comprehension"
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="h-4 w-4 mr-2" /> Generate Assignments
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
AI generated {generated.length} assignments. Edit or remove before saving.
|
||||||
|
</p>
|
||||||
|
{generated.map((item, i) => {
|
||||||
|
const row = exerciseLabel(item, i);
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-lg border p-3 space-y-1">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{row.title}</p>
|
||||||
|
{row.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{row.description}</p>
|
||||||
|
)}
|
||||||
|
{row.marks !== undefined && !Number.isNaN(row.marks) && (
|
||||||
|
<p className="text-xs font-semibold mt-1">{row.marks} marks</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => handleRemove(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button className="flex-1">Save All</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setLocalExercises(null);
|
||||||
|
generateMutation.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/ai/AiGradeExplainer.tsx
Normal file
78
src/components/ai/AiGradeExplainer.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { coachingService } from "@/services/coaching.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export default function AiGradeExplainer({
|
||||||
|
studentName,
|
||||||
|
scores,
|
||||||
|
}: {
|
||||||
|
studentName: string;
|
||||||
|
scores?: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const explainMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
coachingService.explain({
|
||||||
|
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
|
||||||
|
scores,
|
||||||
|
}),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Could not explain grades",
|
||||||
|
description: err.message || "Try again in a moment.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
explainMutation.reset();
|
||||||
|
explainMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleOpen} title="AI Explain Grade">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||||
|
</Button>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
setOpen(v);
|
||||||
|
if (!v) explainMutation.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
AI Grade Explanation — {studentName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{explainMutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing grades...
|
||||||
|
</div>
|
||||||
|
) : explainMutation.isError ? (
|
||||||
|
<p className="text-sm text-destructive text-center py-4">
|
||||||
|
Something went wrong. Close and try again.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg bg-muted/30 p-4">
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
{explainMutation.data?.explanation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/ai/AiGradingAssistant.tsx
Normal file
100
src/components/ai/AiGradingAssistant.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onAccept: (marks: number, feedback: string) => void;
|
||||||
|
submissionId?: number;
|
||||||
|
submissionText?: string;
|
||||||
|
rubricId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEXT =
|
||||||
|
"Sample submission for AI grading suggestion. Replace by passing submissionText when integrating with real submissions.";
|
||||||
|
|
||||||
|
export default function AiGradingAssistant({
|
||||||
|
onAccept,
|
||||||
|
submissionId = 1,
|
||||||
|
submissionText = DEFAULT_TEXT,
|
||||||
|
rubricId,
|
||||||
|
}: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const gradeMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
analyticsService.getGradingSuggestion({
|
||||||
|
submission_id: submissionId,
|
||||||
|
text: submissionText,
|
||||||
|
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
|
||||||
|
}),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Could not load AI grade",
|
||||||
|
description: err.message || "Try again later.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gradeMutation.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when inputs change
|
||||||
|
}, [submissionId, submissionText, rubricId]);
|
||||||
|
|
||||||
|
const data = gradeMutation.data;
|
||||||
|
const marks = data ? Math.round(data.overall_score) : 0;
|
||||||
|
const feedbackBlock = data
|
||||||
|
? [
|
||||||
|
data.feedback,
|
||||||
|
data.suggestions?.length
|
||||||
|
? `Suggestions:\n${data.suggestions.map((s) => `• ${s}`).join("\n")}`
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||||
|
<p className="text-xs font-semibold text-primary flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> AI Suggested Grade
|
||||||
|
</p>
|
||||||
|
{gradeMutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" /> Analyzing submission...
|
||||||
|
</div>
|
||||||
|
) : gradeMutation.isError ? (
|
||||||
|
<p className="text-sm text-destructive">Could not load a suggestion. Check the console or try again.</p>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Suggested marks</p>
|
||||||
|
<p className="text-2xl font-bold">{marks}/100</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-1">Suggested feedback</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{feedbackBlock}</p>
|
||||||
|
</div>
|
||||||
|
{data.scores && Object.keys(data.scores).length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Rubric scores</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-0.5">
|
||||||
|
{Object.entries(data.scores).map(([k, v]) => (
|
||||||
|
<li key={k}>
|
||||||
|
{k}: {v}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={() => onAccept(marks, feedbackBlock)}>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 mr-1" /> Accept AI Grade
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/ai/AiInsightsPanel.tsx
Normal file
105
src/components/ai/AiInsightsPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Sparkles, TrendingUp, AlertTriangle, Trophy, Loader2 } from "lucide-react";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
import type { AiInsight } from "@/types";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const EMPTY_PAYLOAD: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
function insightIcon(type: AiInsight["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "positive":
|
||||||
|
return Trophy;
|
||||||
|
case "warning":
|
||||||
|
return AlertTriangle;
|
||||||
|
default:
|
||||||
|
return TrendingUp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insightColor(type: AiInsight["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "positive":
|
||||||
|
return "text-primary";
|
||||||
|
case "warning":
|
||||||
|
return "text-warning";
|
||||||
|
default:
|
||||||
|
return "text-success";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
title: "Insights unavailable",
|
||||||
|
description: err.message || "Could not load AI insights.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutation.mutate(data);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
|
||||||
|
}, [payloadKey]);
|
||||||
|
|
||||||
|
const items = mutation.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
AI Platform Insights
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
Loading insights…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mutation.isError && !mutation.isPending && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
|
||||||
|
)}
|
||||||
|
{mutation.isSuccess && items.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
|
||||||
|
)}
|
||||||
|
{!mutation.isPending && items.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = insightIcon(item.type);
|
||||||
|
const color = insightColor(item.type);
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon className={`h-4 w-4 ${color}`} />
|
||||||
|
<span className="text-sm font-semibold">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||||
|
{item.metric != null && item.value != null && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{item.metric}: {item.value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/ai/AiReportNarrative.tsx
Normal file
56
src/components/ai/AiReportNarrative.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
report_type: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiReportNarrative({ report_type, data }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const dataKey = useMemo(() => JSON.stringify(data), [data]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (vars: { report_type: string; data: Record<string, unknown> }) =>
|
||||||
|
analyticsService.getReportNarrative(vars),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
title: "Summary unavailable",
|
||||||
|
description: err.message || "Could not generate AI summary.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutation.mutate({ report_type, data });
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [report_type, dataKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-primary/5 p-4 mb-4 flex items-start gap-3">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs font-semibold text-primary">AI Summary</span>
|
||||||
|
{mutation.isPending && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||||
|
Generating summary…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mutation.isError && !mutation.isPending && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Could not load summary.</p>
|
||||||
|
)}
|
||||||
|
{!mutation.isPending && mutation.data?.narrative && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{mutation.data.narrative}</p>
|
||||||
|
)}
|
||||||
|
{mutation.isSuccess && !mutation.data?.narrative?.trim() && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">No summary returned.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/ai/AiRiskBadge.tsx
Normal file
45
src/components/ai/AiRiskBadge.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export type AiRiskLevel = "At Risk" | "Monitor" | "On Track";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
riskLevel?: AiRiskLevel;
|
||||||
|
reasons?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiRiskBadge({ riskLevel, reasons }: Props) {
|
||||||
|
if (!riskLevel) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline" className="text-xs cursor-help">
|
||||||
|
Unknown
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-xs max-w-[200px]">Risk data not available from profile.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variant: "destructive" | "secondary" | "default" =
|
||||||
|
riskLevel === "At Risk" ? "destructive" : riskLevel === "Monitor" ? "secondary" : "default";
|
||||||
|
|
||||||
|
const tooltipText =
|
||||||
|
reasons?.filter(Boolean).length ? reasons!.filter(Boolean).join(" · ") : "No additional details provided.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant={variant} className="text-xs cursor-help">
|
||||||
|
{riskLevel}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-xs max-w-[200px]">{tooltipText}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/ai/AiSearchBar.tsx
Normal file
103
src/components/ai/AiSearchBar.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export default function AiSearchBar() {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const searchMutation = useMutation({
|
||||||
|
mutationFn: (q: string) => analyticsService.search(q),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Search failed",
|
||||||
|
description: err.message || "Could not complete AI search.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!query.trim() || searchMutation.isPending) return;
|
||||||
|
searchMutation.mutate(query.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = searchMutation.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative max-w-md w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||||
|
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
||||||
|
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
searchMutation.reset();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
searchMutation.reset();
|
||||||
|
}}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(searchMutation.isPending || results !== undefined) && (
|
||||||
|
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||||
|
{searchMutation.isPending ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
AI is searching...
|
||||||
|
</div>
|
||||||
|
) : results && results.length > 0 ? (
|
||||||
|
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={`${r.title}-${i}`}
|
||||||
|
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{r.title}</p>
|
||||||
|
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
|
||||||
|
{r.url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary mt-1 hover:underline"
|
||||||
|
onClick={() => navigate(r.url!)}
|
||||||
|
>
|
||||||
|
Go to {r.url}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm flex items-start gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<p>
|
||||||
|
No results for "{query}". Try a different question or keyword.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/ai/AiStudyCoach.tsx
Normal file
102
src/components/ai/AiStudyCoach.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Sparkles, RefreshCw, Loader2, Lightbulb } from "lucide-react";
|
||||||
|
import { coachingService } from "@/services/coaching.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export default function AiStudyCoach() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const suggestMutation = useMutation({
|
||||||
|
mutationFn: () => coachingService.suggest(),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Could not load coach tips",
|
||||||
|
description: err.message || "Try refreshing in a moment.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
suggestMutation.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
suggestMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = suggestMutation.data?.suggestions ?? [];
|
||||||
|
const planTips = suggestMutation.data?.study_plan_tips ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm bg-primary/5">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
|
Your AI Study Coach
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={suggestMutation.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${suggestMutation.isPending ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{suggestMutation.isPending && !suggestMutation.data ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing your performance...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
|
||||||
|
<Lightbulb className="h-3.5 w-3.5" /> Suggestions
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<li key={i}>{s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{planTips.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" /> Study plan tips
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{planTips.map((tip, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-lg border bg-card p-3 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<p className="text-sm">{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!suggestMutation.isPending &&
|
||||||
|
suggestions.length === 0 &&
|
||||||
|
planTips.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No suggestions yet. Try refreshing.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/ai/AiTipBanner.tsx
Normal file
81
src/components/ai/AiTipBanner.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { coachingService } from "@/services/coaching.service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
context?: string;
|
||||||
|
variant?: "tip" | "insight" | "recommendation";
|
||||||
|
dismissible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
|
queryKey: ["ai", "tip", context],
|
||||||
|
queryFn: () => coachingService.getTip(context),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dismissed) return null;
|
||||||
|
|
||||||
|
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">Loading AI tip…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{dismissible && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.content?.trim() && !data.title?.trim()) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
||||||
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-semibold text-primary">
|
||||||
|
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
|
||||||
|
</div>
|
||||||
|
{dismissible && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/components/ai/AiWritingHelper.tsx
Normal file
140
src/components/ai/AiWritingHelper.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { Sparkles, Loader2, PenLine, CheckCircle, BarChart3, ChevronDown } from "lucide-react";
|
||||||
|
import { coachingService } from "@/services/coaching.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Mode = "improve" | "grammar" | "band" | null;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
task_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiWritingHelper({ text, task_type = "ielts_writing" }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeMode, setActiveMode] = useState<Mode>(null);
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (mode: NonNullable<Mode>) =>
|
||||||
|
coachingService.writingHelp({
|
||||||
|
text: text.trim(),
|
||||||
|
task_type: `${task_type}:${mode}`,
|
||||||
|
}),
|
||||||
|
onSuccess: () => setShowResult(true),
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast({
|
||||||
|
title: "Writing help failed",
|
||||||
|
description: err.message || "Could not analyze your writing. Try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAction = (mode: Mode) => {
|
||||||
|
if (!mode) return;
|
||||||
|
if (!text.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Add some text first",
|
||||||
|
description: "Enter your draft in the text area so AI can analyze it.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveMode(mode);
|
||||||
|
setShowResult(false);
|
||||||
|
mutation.mutate(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = mutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="w-full justify-between mt-3">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||||
|
AI Writing Helper
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-3 space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleAction("improve")} disabled={loading}>
|
||||||
|
<PenLine className="h-3.5 w-3.5 mr-1" /> Improve my draft
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleAction("grammar")} disabled={loading}>
|
||||||
|
<CheckCircle className="h-3.5 w-3.5 mr-1" /> Check grammar
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleAction("band")} disabled={loading}>
|
||||||
|
<BarChart3 className="h-3.5 w-3.5 mr-1" /> Estimate band score
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground rounded-lg bg-muted/50 p-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" /> AI is analyzing your writing...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showResult && !loading && mutation.data && activeMode === "improve" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mutation.data.feedback && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> Feedback
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mutation.data.improved && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> Improved Version
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">{mutation.data.improved}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showResult && !loading && mutation.data && activeMode === "grammar" && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> Grammar notes
|
||||||
|
</p>
|
||||||
|
{(mutation.data.grammar_notes?.length ?? 0) > 0 ? (
|
||||||
|
mutation.data.grammar_notes!.map((note, i) => (
|
||||||
|
<div key={i} className="text-sm border-l-2 border-warning pl-2">
|
||||||
|
<p className="text-muted-foreground">{note}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
|
||||||
|
)}
|
||||||
|
{mutation.data.feedback ? (
|
||||||
|
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showResult && !loading && mutation.data && activeMode === "band" && (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" /> Estimated band / assessment
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{mutation.data.feedback}</p>
|
||||||
|
{mutation.data.improved ? (
|
||||||
|
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/ui/accordion.tsx
Normal file
52
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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 };
|
||||||
104
src/components/ui/alert-dialog.tsx
Normal file
104
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
43
src/components/ui/alert.tsx
Normal file
43
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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 };
|
||||||
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root;
|
||||||
|
|
||||||
|
export { AspectRatio };
|
||||||
38
src/components/ui/avatar.tsx
Normal file
38
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 };
|
||||||
29
src/components/ui/badge.tsx
Normal file
29
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 };
|
||||||
90
src/components/ui/breadcrumb.tsx
Normal file
90
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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]:size-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,
|
||||||
|
};
|
||||||
47
src/components/ui/button.tsx
Normal file
47
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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 };
|
||||||
54
src/components/ui/calendar.tsx
Normal file
54
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { DayPicker } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||||
|
|
||||||
|
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar";
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
43
src/components/ui/card.tsx
Normal file
43
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<p 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 };
|
||||||
224
src/components/ui/carousel.tsx
Normal file
224
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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 };
|
||||||
303
src/components/ui/chart.tsx
Normal file
303
src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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 };
|
||||||
26
src/components/ui/checkbox.tsx
Normal file
26
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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 data-[state=checked]:bg-primary data-[state=checked]:text-primary-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,
|
||||||
|
)}
|
||||||
|
{...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 };
|
||||||
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
132
src/components/ui/command.tsx
Normal file
132
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
interface CommandDialogProps extends DialogProps {}
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
|
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 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",
|
||||||
|
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,
|
||||||
|
};
|
||||||
178
src/components/ui/context-menu.tsx
Normal file
178
src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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,
|
||||||
|
};
|
||||||
95
src/components/ui/dialog.tsx
Normal file
95
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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 data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<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,
|
||||||
|
};
|
||||||
87
src/components/ui/drawer.tsx
Normal file
87
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
179
src/components/ui/dropdown-menu.tsx
Normal file
179
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</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 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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,
|
||||||
|
};
|
||||||
129
src/components/ui/form.tsx
Normal file
129
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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 };
|
||||||
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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 };
|
||||||
61
src/components/ui/input-otp.tsx
Normal file
61
src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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="animate-caret-blink h-4 w-px 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
src/components/ui/input.tsx
Normal file
22
src/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 };
|
||||||
17
src/components/ui/label.tsx
Normal file
17
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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 };
|
||||||
207
src/components/ui/menubar.tsx
Normal file
207
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus: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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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,
|
||||||
|
};
|
||||||
120
src/components/ui/navigation-menu.tsx
Normal file
120
src/components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
81
src/components/ui/pagination.tsx
Normal file
81
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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 };
|
||||||
23
src/components/ui/progress.tsx
Normal file
23
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 };
|
||||||
36
src/components/ui/radio-group.tsx
Normal file
36
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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 };
|
||||||
37
src/components/ui/resizable.tsx
Normal file
37
src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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 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 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[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 };
|
||||||
38
src/components/ui/scroll-area.tsx
Normal file
38
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 };
|
||||||
143
src/components/ui/select.tsx
Normal file
143
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
|
||||||
|
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,
|
||||||
|
};
|
||||||
20
src/components/ui/separator.tsx
Normal file
20
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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 };
|
||||||
107
src/components/ui/sheet.tsx
Normal file
107
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import * as React from "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 data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<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,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetPortal,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
};
|
||||||
637
src/components/ui/sidebar.tsx
Normal file
637
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
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 text-sidebar-foreground md:block"
|
||||||
|
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(
|
||||||
|
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 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(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 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] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border 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(
|
||||||
|
"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] duration-200 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 peer-hover/menu-button:text-sidebar-accent-foreground 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",
|
||||||
|
"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(
|
||||||
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||||
|
"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("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||||
|
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 aria-disabled:pointer-events-none aria-disabled:opacity-50 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 [&>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,
|
||||||
|
};
|
||||||
7
src/components/ui/skeleton.tsx
Normal file
7
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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 };
|
||||||
23
src/components/ui/slider.tsx
Normal file
23
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 };
|
||||||
27
src/components/ui/sonner.tsx
Normal file
27
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Toaster as Sonner, toast } 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, toast };
|
||||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input 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",
|
||||||
|
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 };
|
||||||
72
src/components/ui/table.tsx
Normal file
72
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
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 data-[state=selected]:bg-muted hover:bg-muted/50", 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 };
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
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
src/components/ui/textarea.tsx
Normal file
21
src/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 };
|
||||||
111
src/components/ui/toast.tsx
Normal file
111
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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 group 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 group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
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 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-2 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
src/components/ui/toaster.tsx
Normal file
24
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/ui/toggle-group.tsx
Normal file
49
src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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 };
|
||||||
37
src/components/ui/toggle.tsx
Normal file
37
src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-3",
|
||||||
|
sm: "h-9 px-2.5",
|
||||||
|
lg: "h-11 px-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 };
|
||||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
3
src/components/ui/use-toast.ts
Normal file
3
src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { useToast, toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
68
src/contexts/AuthContext.tsx
Normal file
68
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
|
import { authService } from "@/services/auth.service";
|
||||||
|
import { clearToken } from "@/lib/api-client";
|
||||||
|
import type { User, UserRole } from "@/types/auth";
|
||||||
|
|
||||||
|
export type { UserRole };
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<User>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("encoach_token");
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authService
|
||||||
|
.getUser()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => {
|
||||||
|
clearToken();
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string): Promise<User> => {
|
||||||
|
const res = await authService.login({ login: email, password });
|
||||||
|
setUser(res.user);
|
||||||
|
return res.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await authService.logout();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user