Spaces:
Sleeping
Sleeping
feat: replace frontend with redesigned Ethos Studio UI + update READMEs
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- .gitattributes +0 -1
- api/README.md +75 -28
- proxy/README.md +35 -23
- web/.gitignore +41 -41
- web/README.md +42 -37
- web/components.json +22 -22
- web/eslint.config.mjs +18 -18
- web/next.config.ts +10 -7
- web/package-lock.json +0 -0
- web/package.json +38 -37
- web/postcss.config.mjs +7 -7
- web/public/file.svg +0 -1
- web/public/globe.svg +0 -1
- web/public/logo-black-bg.svg +6 -0
- web/public/logo.svg +5 -0
- web/public/next.svg +0 -1
- web/public/vercel.svg +0 -1
- web/public/window.svg +0 -1
- web/src/app/api/speech-to-text/route.ts +51 -0
- web/src/app/favicon.ico +0 -0
- web/src/app/globals.css +148 -148
- web/src/app/layout.tsx +43 -33
- web/src/app/page.tsx +180 -369
- web/src/app/studio/page.tsx +574 -528
- web/src/components/navbar.tsx +96 -33
- web/src/components/sidebar.tsx +178 -0
- web/src/components/ui/avatar.tsx +112 -112
- web/src/components/ui/badge.tsx +49 -49
- web/src/components/ui/button.tsx +65 -65
- web/src/components/ui/card.tsx +100 -100
- web/src/components/ui/collapsible.tsx +33 -33
- web/src/components/ui/dialog.tsx +165 -165
- web/src/components/ui/dropdown-menu.tsx +269 -269
- web/src/components/ui/hover-card.tsx +44 -44
- web/src/components/ui/input.tsx +19 -19
- web/src/components/ui/label.tsx +24 -24
- web/src/components/ui/progress.tsx +31 -31
- web/src/components/ui/scroll-area.tsx +55 -55
- web/src/components/ui/select.tsx +195 -195
- web/src/components/ui/separator.tsx +28 -28
- web/src/components/ui/sheet.tsx +144 -0
- web/src/components/ui/sidebar.tsx +705 -0
- web/src/components/ui/skeleton.tsx +13 -13
- web/src/components/ui/slider.tsx +59 -59
- web/src/components/ui/switch.tsx +33 -33
- web/src/components/ui/table.tsx +116 -116
- web/src/components/ui/tabs.tsx +90 -90
- web/src/components/ui/tooltip.tsx +57 -57
- web/src/hooks/use-mobile.ts +19 -0
- web/src/lib/session-store.ts +257 -93
.gitattributes
CHANGED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
api/README.md
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
**Requirements**: Python 3.
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
## Startup
|
| 10 |
|
| 11 |
```bash
|
| 12 |
-
cd
|
| 13 |
python -m venv .venv
|
| 14 |
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 15 |
pip install -r requirements.txt
|
| 16 |
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 17 |
```
|
| 18 |
|
| 19 |
-
Default port: **8000**.
|
| 20 |
|
| 21 |
---
|
| 22 |
|
|
@@ -24,14 +24,13 @@ Default port: **8000**. Starts instantly β inference is handled remotely.
|
|
| 24 |
|
| 25 |
### POST /transcribe
|
| 26 |
|
| 27 |
-
Simple transcription. Audio is converted to WAV and
|
| 28 |
-
The transcription text may include inline expression tags like `[laughs]`, `[sighs]`, `[whispers]`.
|
| 29 |
|
| 30 |
| | |
|
| 31 |
|--|--|
|
| 32 |
| **Content-Type** | `multipart/form-data` |
|
| 33 |
-
| **Body** | `audio` β audio file (required) |
|
| 34 |
-
| **Formats** | wav, mp3, flac, ogg, m4a, webm |
|
| 35 |
| **Max size** | `MAX_UPLOAD_MB` (default 100 MB) |
|
| 36 |
|
| 37 |
**Response (200)**
|
|
@@ -48,20 +47,18 @@ The transcription text may include inline expression tags like `[laughs]`, `[sig
|
|
| 48 |
|
| 49 |
### POST /transcribe-diarize
|
| 50 |
|
| 51 |
-
Full pipeline:
|
| 52 |
-
All segments are labelled `SPEAKER_00` (single-speaker mode).
|
| 53 |
-
Emotion is derived from evoxtral's inline expression tags (`[laughs]` β Happy, `[sighs]` β Sad, etc.).
|
| 54 |
|
| 55 |
| | |
|
| 56 |
|--|--|
|
| 57 |
| **Content-Type** | `multipart/form-data` |
|
| 58 |
-
| **Body** | `audio` β audio file (required) |
|
| 59 |
-
| **Formats** | wav, mp3, flac, ogg, m4a, webm |
|
| 60 |
| **Max size** | `MAX_UPLOAD_MB` (default 100 MB) |
|
| 61 |
|
| 62 |
-
Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merged.
|
| 63 |
|
| 64 |
-
**Response (200)**
|
| 65 |
|
| 66 |
```json
|
| 67 |
{
|
|
@@ -80,11 +77,37 @@ Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merg
|
|
| 80 |
"duration": 5.65,
|
| 81 |
"text": "Hello! [laughs] How are you?",
|
| 82 |
"filename": "audio.m4a",
|
| 83 |
-
"diarization_method": "vad"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
```
|
| 86 |
|
| 87 |
-
`
|
| 88 |
|
| 89 |
**Errors**
|
| 90 |
|
|
@@ -92,7 +115,7 @@ Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merg
|
|
| 92 |
|--------|---------|
|
| 93 |
| 400 | No/invalid file, empty, or unsupported format |
|
| 94 |
| 413 | File exceeds `MAX_UPLOAD_MB` |
|
| 95 |
-
|
|
| 96 |
|
| 97 |
---
|
| 98 |
|
|
@@ -103,13 +126,29 @@ Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merg
|
|
| 103 |
```json
|
| 104 |
{
|
| 105 |
"status": "ok",
|
| 106 |
-
"model": "YongkangZOU/evoxtral-lora (
|
| 107 |
"model_loaded": true,
|
| 108 |
"ffmpeg": true,
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"max_upload_mb": 100
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
```
|
| 115 |
|
|
@@ -119,7 +158,9 @@ Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merg
|
|
| 119 |
|
| 120 |
| Variable | Default | Description |
|
| 121 |
|----------|---------|-------------|
|
| 122 |
-
| `
|
|
|
|
|
|
|
| 123 |
| `MAX_UPLOAD_MB` | `100` | Max upload size in MB |
|
| 124 |
|
| 125 |
---
|
|
@@ -127,12 +168,18 @@ Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merg
|
|
| 127 |
## Usage examples
|
| 128 |
|
| 129 |
```bash
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
# Simple transcription
|
| 131 |
curl -X POST http://127.0.0.1:8000/transcribe -F "audio=@audio.m4a"
|
| 132 |
|
| 133 |
-
#
|
| 134 |
curl -X POST http://127.0.0.1:8000/transcribe-diarize -F "audio=@audio.m4a"
|
| 135 |
|
| 136 |
-
#
|
| 137 |
-
curl -
|
| 138 |
```
|
|
|
|
| 1 |
+
# API Layer (Python FastAPI β port 8000)
|
| 2 |
|
| 3 |
+
Local Voxtral inference pipeline. Loads `mistralai/Voxtral-Mini-3B-2507` + `YongkangZOU/evoxtral-lora` (PEFT adapter) locally, runs VAD sentence segmentation, per-segment emotion tagging, and facial emotion recognition (FER) for video inputs.
|
| 4 |
|
| 5 |
+
**Requirements**: Python 3.11+, system **ffmpeg** (`brew install ffmpeg` / `apt install ffmpeg`). GPU with ~8 GB VRAM recommended; CPU fallback supported (expect ~50 s per audio second).
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
## Startup
|
| 10 |
|
| 11 |
```bash
|
| 12 |
+
cd api
|
| 13 |
python -m venv .venv
|
| 14 |
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 15 |
pip install -r requirements.txt
|
| 16 |
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 17 |
```
|
| 18 |
|
| 19 |
+
Default port: **8000**. On first start the Voxtral model (~8 GB total) is downloaded from HuggingFace. Set `HF_HUB_DISABLE_XET=1` if download stalls behind a local proxy.
|
| 20 |
|
| 21 |
---
|
| 22 |
|
|
|
|
| 24 |
|
| 25 |
### POST /transcribe
|
| 26 |
|
| 27 |
+
Simple transcription. Audio is converted to WAV and passed to the local Voxtral model.
|
|
|
|
| 28 |
|
| 29 |
| | |
|
| 30 |
|--|--|
|
| 31 |
| **Content-Type** | `multipart/form-data` |
|
| 32 |
+
| **Body** | `audio` β audio or video file (required) |
|
| 33 |
+
| **Formats** | wav, mp3, flac, ogg, m4a, webm, mp4, mov, mkv |
|
| 34 |
| **Max size** | `MAX_UPLOAD_MB` (default 100 MB) |
|
| 35 |
|
| 36 |
**Response (200)**
|
|
|
|
| 47 |
|
| 48 |
### POST /transcribe-diarize
|
| 49 |
|
| 50 |
+
Full pipeline: local Voxtral STT + VAD sentence segmentation + per-segment emotion tagging. For video inputs, also runs per-frame FER via MobileViT-XXS ONNX and adds `face_emotion` per segment.
|
|
|
|
|
|
|
| 51 |
|
| 52 |
| | |
|
| 53 |
|--|--|
|
| 54 |
| **Content-Type** | `multipart/form-data` |
|
| 55 |
+
| **Body** | `audio` β audio or video file (required) |
|
| 56 |
+
| **Formats** | wav, mp3, flac, ogg, m4a, webm, mp4, mov, mkv |
|
| 57 |
| **Max size** | `MAX_UPLOAD_MB` (default 100 MB) |
|
| 58 |
|
| 59 |
+
Segmentation: silence gaps β₯ 0.3 s create a new segment; gaps < 0.3 s are merged.
|
| 60 |
|
| 61 |
+
**Response (200) β audio input**
|
| 62 |
|
| 63 |
```json
|
| 64 |
{
|
|
|
|
| 77 |
"duration": 5.65,
|
| 78 |
"text": "Hello! [laughs] How are you?",
|
| 79 |
"filename": "audio.m4a",
|
| 80 |
+
"diarization_method": "vad",
|
| 81 |
+
"has_video": false
|
| 82 |
+
}
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
**Response (200) β video input** (adds `face_emotion` per segment)
|
| 86 |
+
|
| 87 |
+
```json
|
| 88 |
+
{
|
| 89 |
+
"segments": [
|
| 90 |
+
{
|
| 91 |
+
"id": 1,
|
| 92 |
+
"speaker": "SPEAKER_00",
|
| 93 |
+
"start": 0.96,
|
| 94 |
+
"end": 3.23,
|
| 95 |
+
"text": "Hello!",
|
| 96 |
+
"emotion": "Happy",
|
| 97 |
+
"valence": 0.7,
|
| 98 |
+
"arousal": 0.6,
|
| 99 |
+
"face_emotion": "Happy"
|
| 100 |
+
}
|
| 101 |
+
],
|
| 102 |
+
"duration": 5.65,
|
| 103 |
+
"text": "Hello!",
|
| 104 |
+
"filename": "video.mov",
|
| 105 |
+
"diarization_method": "vad",
|
| 106 |
+
"has_video": true
|
| 107 |
}
|
| 108 |
```
|
| 109 |
|
| 110 |
+
`face_emotion` values: `Anger | Contempt | Disgust | Fear | Happy | Neutral | Sad | Surprise`
|
| 111 |
|
| 112 |
**Errors**
|
| 113 |
|
|
|
|
| 115 |
|--------|---------|
|
| 116 |
| 400 | No/invalid file, empty, or unsupported format |
|
| 117 |
| 413 | File exceeds `MAX_UPLOAD_MB` |
|
| 118 |
+
| 500 | Transcription or inference error |
|
| 119 |
|
| 120 |
---
|
| 121 |
|
|
|
|
| 126 |
```json
|
| 127 |
{
|
| 128 |
"status": "ok",
|
| 129 |
+
"model": "mistralai/Voxtral-Mini-3B-2507 + YongkangZOU/evoxtral-lora (local)",
|
| 130 |
"model_loaded": true,
|
| 131 |
"ffmpeg": true,
|
| 132 |
+
"fer_enabled": true,
|
| 133 |
+
"device": "cpu",
|
| 134 |
+
"max_upload_mb": 100
|
| 135 |
+
}
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
### GET /debug-inference
|
| 141 |
+
|
| 142 |
+
Smoke-test endpoint: synthesizes 0.5 s of silence and runs a minimal `generate()` call. Useful for verifying the model is loaded and functional without uploading a real file.
|
| 143 |
+
|
| 144 |
+
**Response (200)**
|
| 145 |
+
|
| 146 |
+
```json
|
| 147 |
+
{
|
| 148 |
+
"ok": true,
|
| 149 |
+
"text": "",
|
| 150 |
+
"dtype": "torch.bfloat16",
|
| 151 |
+
"device": "cpu"
|
| 152 |
}
|
| 153 |
```
|
| 154 |
|
|
|
|
| 158 |
|
| 159 |
| Variable | Default | Description |
|
| 160 |
|----------|---------|-------------|
|
| 161 |
+
| `MODEL_ID` | `mistralai/Voxtral-Mini-3B-2507` | Base Voxtral model on HF Hub |
|
| 162 |
+
| `ADAPTER_ID` | `YongkangZOU/evoxtral-lora` | PEFT LoRA adapter on HF Hub |
|
| 163 |
+
| `FER_MODEL_PATH` | (auto-detected) | Path to `emotion_model_web.onnx`; auto-detects `/app/models/` (Docker) and `../models/` (local) |
|
| 164 |
| `MAX_UPLOAD_MB` | `100` | Max upload size in MB |
|
| 165 |
|
| 166 |
---
|
|
|
|
| 168 |
## Usage examples
|
| 169 |
|
| 170 |
```bash
|
| 171 |
+
# Health
|
| 172 |
+
curl -s http://127.0.0.1:8000/health
|
| 173 |
+
|
| 174 |
+
# Smoke-test inference
|
| 175 |
+
curl -s http://127.0.0.1:8000/debug-inference
|
| 176 |
+
|
| 177 |
# Simple transcription
|
| 178 |
curl -X POST http://127.0.0.1:8000/transcribe -F "audio=@audio.m4a"
|
| 179 |
|
| 180 |
+
# Full pipeline (audio)
|
| 181 |
curl -X POST http://127.0.0.1:8000/transcribe-diarize -F "audio=@audio.m4a"
|
| 182 |
|
| 183 |
+
# Full pipeline (video β also returns face_emotion per segment)
|
| 184 |
+
curl -X POST http://127.0.0.1:8000/transcribe-diarize -F "audio=@video.mov"
|
| 185 |
```
|
proxy/README.md
CHANGED
|
@@ -1,22 +1,23 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
- **Port**: `3000` (override with `PORT`)
|
| 6 |
-
- **
|
| 7 |
|
| 8 |
---
|
| 9 |
|
| 10 |
## Startup
|
| 11 |
|
| 12 |
```bash
|
|
|
|
| 13 |
npm install
|
| 14 |
npm run dev # dev with --watch
|
| 15 |
# or
|
| 16 |
npm start
|
| 17 |
```
|
| 18 |
|
| 19 |
-
Requires **Node.js
|
| 20 |
|
| 21 |
---
|
| 22 |
|
|
@@ -24,7 +25,7 @@ Requires **Node.js 18+**.
|
|
| 24 |
|
| 25 |
### POST /api/speech-to-text
|
| 26 |
|
| 27 |
-
Simple transcription. Forwarded to
|
| 28 |
|
| 29 |
| | |
|
| 30 |
|--|--|
|
|
@@ -38,7 +39,7 @@ Simple transcription. Forwarded to Model layer `POST /transcribe`. Timeout: **5
|
|
| 38 |
{
|
| 39 |
"text": "transcribed text",
|
| 40 |
"words": [],
|
| 41 |
-
"languageCode":
|
| 42 |
}
|
| 43 |
```
|
| 44 |
|
|
@@ -47,19 +48,19 @@ Simple transcription. Forwarded to Model layer `POST /transcribe`. Timeout: **5
|
|
| 47 |
| Status | Body |
|
| 48 |
|--------|------|
|
| 49 |
| 400 | `{"error": "Upload an audio file (form field: audio)"}` |
|
| 50 |
-
| 502 |
|
| 51 |
-
| 504 | `{"error": "Request timeout (>
|
| 52 |
|
| 53 |
---
|
| 54 |
|
| 55 |
### POST /api/transcribe-diarize
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
| | |
|
| 60 |
|--|--|
|
| 61 |
| **Content-Type** | `multipart/form-data` |
|
| 62 |
-
| **Body** | `audio` β audio file (wav, mp3, flac, ogg, m4a, webm) |
|
| 63 |
| **Limits** | β€ 100 MB |
|
| 64 |
|
| 65 |
**Response (200)**
|
|
@@ -73,25 +74,29 @@ Transcription + VAD sentence segmentation + emotion analysis. Forwarded to Model
|
|
| 73 |
"start": 0.0,
|
| 74 |
"end": 4.2,
|
| 75 |
"text": "Hello, how are you?",
|
| 76 |
-
"emotion": "
|
| 77 |
-
"valence": 0.
|
| 78 |
-
"arousal": 0.
|
|
|
|
| 79 |
}
|
| 80 |
],
|
| 81 |
"duration": 42.3,
|
| 82 |
"text": "full transcript",
|
| 83 |
-
"filename": "recording.
|
| 84 |
-
"diarization_method": "vad"
|
|
|
|
| 85 |
}
|
| 86 |
```
|
| 87 |
|
|
|
|
|
|
|
| 88 |
**Errors**
|
| 89 |
|
| 90 |
| Status | Body |
|
| 91 |
|--------|------|
|
| 92 |
| 400 | `{"error": "Upload an audio file (form field: audio)"}` |
|
| 93 |
-
| 502 |
|
| 94 |
-
| 504 | `{"error": "Request timeout (>
|
| 95 |
|
| 96 |
---
|
| 97 |
|
|
@@ -107,17 +112,17 @@ Proxies `GET {MODEL_URL}/health` and wraps it.
|
|
| 107 |
"server": "ser-server",
|
| 108 |
"model": {
|
| 109 |
"status": "ok",
|
| 110 |
-
"model": "mistralai/Voxtral-Mini-
|
| 111 |
"model_loaded": true,
|
| 112 |
"ffmpeg": true,
|
| 113 |
-
"
|
| 114 |
-
"
|
| 115 |
"max_upload_mb": 100
|
| 116 |
}
|
| 117 |
}
|
| 118 |
```
|
| 119 |
|
| 120 |
-
**Response (502)** β when
|
| 121 |
|
| 122 |
```json
|
| 123 |
{"ok": false, "error": "Cannot reach Model layer; start model/voxtral-server first", "url": "http://127.0.0.1:8000"}
|
|
@@ -125,15 +130,22 @@ Proxies `GET {MODEL_URL}/health` and wraps it.
|
|
| 125 |
|
| 126 |
---
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
## Usage examples
|
| 129 |
|
| 130 |
```bash
|
| 131 |
# Health
|
| 132 |
curl -s http://localhost:3000/health
|
| 133 |
|
| 134 |
-
# Transcribe
|
| 135 |
curl -X POST http://localhost:3000/api/speech-to-text -F "audio=@./recording.m4a"
|
| 136 |
|
| 137 |
-
# Transcribe + segment + emotion
|
| 138 |
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@./recording.m4a"
|
|
|
|
| 139 |
```
|
|
|
|
| 1 |
+
# Proxy Layer (Node/Express β port 3000)
|
| 2 |
|
| 3 |
+
API gateway. Accepts multipart file uploads from the browser, forwards them to the **API layer** (Python FastAPI on port 8000), and returns JSON responses.
|
| 4 |
|
| 5 |
- **Port**: `3000` (override with `PORT`)
|
| 6 |
+
- **API layer URL**: `http://127.0.0.1:8000` (override with `MODEL_URL`)
|
| 7 |
|
| 8 |
---
|
| 9 |
|
| 10 |
## Startup
|
| 11 |
|
| 12 |
```bash
|
| 13 |
+
cd proxy
|
| 14 |
npm install
|
| 15 |
npm run dev # dev with --watch
|
| 16 |
# or
|
| 17 |
npm start
|
| 18 |
```
|
| 19 |
|
| 20 |
+
Requires **Node.js 22+**.
|
| 21 |
|
| 22 |
---
|
| 23 |
|
|
|
|
| 25 |
|
| 26 |
### POST /api/speech-to-text
|
| 27 |
|
| 28 |
+
Simple transcription. Forwarded to API layer `POST /transcribe`. Timeout: **30 min** (CPU inference is slow).
|
| 29 |
|
| 30 |
| | |
|
| 31 |
|--|--|
|
|
|
|
| 39 |
{
|
| 40 |
"text": "transcribed text",
|
| 41 |
"words": [],
|
| 42 |
+
"languageCode": "en"
|
| 43 |
}
|
| 44 |
```
|
| 45 |
|
|
|
|
| 48 |
| Status | Body |
|
| 49 |
|--------|------|
|
| 50 |
| 400 | `{"error": "Upload an audio file (form field: audio)"}` |
|
| 51 |
+
| 502 | API layer error or unreachable |
|
| 52 |
+
| 504 | `{"error": "Request timeout (>30 min); try shorter audio"}` |
|
| 53 |
|
| 54 |
---
|
| 55 |
|
| 56 |
### POST /api/transcribe-diarize
|
| 57 |
|
| 58 |
+
Full pipeline: transcription + VAD sentence segmentation + emotion analysis. For video inputs, also returns `face_emotion` per segment. Forwarded to API layer `POST /transcribe-diarize`. Timeout: **60 min**.
|
| 59 |
|
| 60 |
| | |
|
| 61 |
|--|--|
|
| 62 |
| **Content-Type** | `multipart/form-data` |
|
| 63 |
+
| **Body** | `audio` β audio or video file (wav, mp3, flac, ogg, m4a, webm, mp4, mov, mkv) |
|
| 64 |
| **Limits** | β€ 100 MB |
|
| 65 |
|
| 66 |
**Response (200)**
|
|
|
|
| 74 |
"start": 0.0,
|
| 75 |
"end": 4.2,
|
| 76 |
"text": "Hello, how are you?",
|
| 77 |
+
"emotion": "Happy",
|
| 78 |
+
"valence": 0.7,
|
| 79 |
+
"arousal": 0.6,
|
| 80 |
+
"face_emotion": "Happy"
|
| 81 |
}
|
| 82 |
],
|
| 83 |
"duration": 42.3,
|
| 84 |
"text": "full transcript",
|
| 85 |
+
"filename": "recording.mov",
|
| 86 |
+
"diarization_method": "vad",
|
| 87 |
+
"has_video": true
|
| 88 |
}
|
| 89 |
```
|
| 90 |
|
| 91 |
+
`face_emotion` is present only when a video file is uploaded and FER is enabled. `has_video` indicates whether facial emotion recognition ran.
|
| 92 |
+
|
| 93 |
**Errors**
|
| 94 |
|
| 95 |
| Status | Body |
|
| 96 |
|--------|------|
|
| 97 |
| 400 | `{"error": "Upload an audio file (form field: audio)"}` |
|
| 98 |
+
| 502 | API layer error or unreachable |
|
| 99 |
+
| 504 | `{"error": "Request timeout (>60 min); try shorter audio"}` |
|
| 100 |
|
| 101 |
---
|
| 102 |
|
|
|
|
| 112 |
"server": "ser-server",
|
| 113 |
"model": {
|
| 114 |
"status": "ok",
|
| 115 |
+
"model": "mistralai/Voxtral-Mini-3B-2507 + YongkangZOU/evoxtral-lora (local)",
|
| 116 |
"model_loaded": true,
|
| 117 |
"ffmpeg": true,
|
| 118 |
+
"fer_enabled": true,
|
| 119 |
+
"device": "cpu",
|
| 120 |
"max_upload_mb": 100
|
| 121 |
}
|
| 122 |
}
|
| 123 |
```
|
| 124 |
|
| 125 |
+
**Response (502)** β when API layer is unreachable:
|
| 126 |
|
| 127 |
```json
|
| 128 |
{"ok": false, "error": "Cannot reach Model layer; start model/voxtral-server first", "url": "http://127.0.0.1:8000"}
|
|
|
|
| 130 |
|
| 131 |
---
|
| 132 |
|
| 133 |
+
### GET /api/debug-inference
|
| 134 |
+
|
| 135 |
+
Proxies `GET {MODEL_URL}/debug-inference` β smoke-tests the local Voxtral model with a short silence clip.
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
## Usage examples
|
| 140 |
|
| 141 |
```bash
|
| 142 |
# Health
|
| 143 |
curl -s http://localhost:3000/health
|
| 144 |
|
| 145 |
+
# Transcribe (audio)
|
| 146 |
curl -X POST http://localhost:3000/api/speech-to-text -F "audio=@./recording.m4a"
|
| 147 |
|
| 148 |
+
# Transcribe + segment + emotion (audio or video)
|
| 149 |
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@./recording.m4a"
|
| 150 |
+
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@./video.mov"
|
| 151 |
```
|
web/.gitignore
CHANGED
|
@@ -1,41 +1,41 @@
|
|
| 1 |
-
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
-
|
| 3 |
-
# dependencies
|
| 4 |
-
/node_modules
|
| 5 |
-
/.pnp
|
| 6 |
-
.pnp.*
|
| 7 |
-
.yarn/*
|
| 8 |
-
!.yarn/patches
|
| 9 |
-
!.yarn/plugins
|
| 10 |
-
!.yarn/releases
|
| 11 |
-
!.yarn/versions
|
| 12 |
-
|
| 13 |
-
# testing
|
| 14 |
-
/coverage
|
| 15 |
-
|
| 16 |
-
# next.js
|
| 17 |
-
/.next/
|
| 18 |
-
/out/
|
| 19 |
-
|
| 20 |
-
# production
|
| 21 |
-
/build
|
| 22 |
-
|
| 23 |
-
# misc
|
| 24 |
-
.DS_Store
|
| 25 |
-
*.pem
|
| 26 |
-
|
| 27 |
-
# debug
|
| 28 |
-
npm-debug.log*
|
| 29 |
-
yarn-debug.log*
|
| 30 |
-
yarn-error.log*
|
| 31 |
-
.pnpm-debug.log*
|
| 32 |
-
|
| 33 |
-
# env files (can opt-in for committing if needed)
|
| 34 |
-
.env*
|
| 35 |
-
|
| 36 |
-
# vercel
|
| 37 |
-
.vercel
|
| 38 |
-
|
| 39 |
-
# typescript
|
| 40 |
-
*.tsbuildinfo
|
| 41 |
-
next-env.d.ts
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
web/README.md
CHANGED
|
@@ -1,41 +1,43 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## Architecture
|
| 6 |
|
| 7 |
```
|
| 8 |
Browser (port 3030)
|
| 9 |
-
β
|
| 10 |
-
β
|
| 11 |
```
|
| 12 |
|
| 13 |
-
- **Frontend** (`
|
| 14 |
-
- **
|
| 15 |
-
- **
|
| 16 |
|
| 17 |
---
|
| 18 |
|
| 19 |
## Startup
|
| 20 |
|
| 21 |
-
### 1.
|
| 22 |
|
| 23 |
-
Requires **Python 3.
|
| 24 |
|
| 25 |
```bash
|
| 26 |
-
cd
|
| 27 |
python -m venv .venv
|
| 28 |
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 29 |
pip install -r requirements.txt
|
| 30 |
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 31 |
```
|
| 32 |
|
| 33 |
-
|
| 34 |
|
| 35 |
-
### 2.
|
| 36 |
|
| 37 |
```bash
|
| 38 |
-
cd
|
| 39 |
npm install
|
| 40 |
npm run dev
|
| 41 |
```
|
|
@@ -43,15 +45,15 @@ npm run dev
|
|
| 43 |
### 3. Frontend (Next.js, port 3030)
|
| 44 |
|
| 45 |
```bash
|
| 46 |
-
cd
|
| 47 |
npm install
|
| 48 |
npm run dev
|
| 49 |
```
|
| 50 |
|
| 51 |
Open [http://localhost:3030](http://localhost:3030).
|
| 52 |
|
| 53 |
-
- **Home page**: Click **Transcribe files**, drag-drop an audio
|
| 54 |
-
- **Studio page** (`/studio`): Three-column layout β transcript segments (speaker + emotion badges) on the left, waveform in the center, audio player on the right.
|
| 55 |
|
| 56 |
### 4. Quick check (API only)
|
| 57 |
|
|
@@ -59,13 +61,14 @@ Open [http://localhost:3030](http://localhost:3030).
|
|
| 59 |
curl -s http://localhost:3000/health
|
| 60 |
curl -X POST http://localhost:3000/api/speech-to-text -F "audio=@audio.m4a"
|
| 61 |
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@audio.m4a"
|
|
|
|
| 62 |
```
|
| 63 |
|
| 64 |
---
|
| 65 |
|
| 66 |
-
## API (
|
| 67 |
|
| 68 |
-
Clients should call the **
|
| 69 |
|
| 70 |
### POST /api/speech-to-text
|
| 71 |
|
|
@@ -75,7 +78,7 @@ Simple transcription without diarization.
|
|
| 75 |
|--|--|
|
| 76 |
| **Content-Type** | `multipart/form-data` |
|
| 77 |
| **Body** | `audio` β audio file (wav, mp3, flac, ogg, m4a, webm) |
|
| 78 |
-
| **Limits** | β€ 100 MB; timeout
|
| 79 |
|
| 80 |
**Response (200)**
|
| 81 |
|
|
@@ -83,7 +86,7 @@ Simple transcription without diarization.
|
|
| 83 |
{
|
| 84 |
"text": "transcribed text",
|
| 85 |
"words": [],
|
| 86 |
-
"languageCode":
|
| 87 |
}
|
| 88 |
```
|
| 89 |
|
|
@@ -91,13 +94,13 @@ Simple transcription without diarization.
|
|
| 91 |
|
| 92 |
### POST /api/transcribe-diarize
|
| 93 |
|
| 94 |
-
Full pipeline: transcription + VAD sentence segmentation + per-segment emotion analysis.
|
| 95 |
|
| 96 |
| | |
|
| 97 |
|--|--|
|
| 98 |
| **Content-Type** | `multipart/form-data` |
|
| 99 |
-
| **Body** | `audio` β audio file (wav, mp3, flac, ogg, m4a, webm) |
|
| 100 |
-
| **Limits** | β€ 100 MB; timeout
|
| 101 |
|
| 102 |
**Response (200)**
|
| 103 |
|
|
@@ -110,19 +113,21 @@ Full pipeline: transcription + VAD sentence segmentation + per-segment emotion a
|
|
| 110 |
"start": 0.0,
|
| 111 |
"end": 4.2,
|
| 112 |
"text": "Hello, how are you?",
|
| 113 |
-
"emotion": "
|
| 114 |
-
"valence": 0.
|
| 115 |
-
"arousal": 0.
|
|
|
|
| 116 |
}
|
| 117 |
],
|
| 118 |
"duration": 42.3,
|
| 119 |
"text": "full transcript text",
|
| 120 |
-
"filename": "recording.
|
| 121 |
-
"diarization_method": "vad"
|
|
|
|
| 122 |
}
|
| 123 |
```
|
| 124 |
|
| 125 |
-
`diarization_method`
|
| 126 |
|
| 127 |
---
|
| 128 |
|
|
@@ -134,11 +139,11 @@ Full pipeline: transcription + VAD sentence segmentation + per-segment emotion a
|
|
| 134 |
"server": "ser-server",
|
| 135 |
"model": {
|
| 136 |
"status": "ok",
|
| 137 |
-
"model": "mistralai/Voxtral-Mini-
|
| 138 |
"model_loaded": true,
|
| 139 |
"ffmpeg": true,
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
"max_upload_mb": 100
|
| 143 |
}
|
| 144 |
}
|
|
@@ -148,15 +153,15 @@ Full pipeline: transcription + VAD sentence segmentation + per-segment emotion a
|
|
| 148 |
|
| 149 |
## Environment variables
|
| 150 |
|
| 151 |
-
Create `
|
| 152 |
|
| 153 |
| Variable | Default | Description |
|
| 154 |
|----------|---------|-------------|
|
| 155 |
-
| `NEXT_PUBLIC_API_URL` | `http://localhost:3000` |
|
| 156 |
|
| 157 |
-
Create `
|
| 158 |
|
| 159 |
| Variable | Default | Description |
|
| 160 |
|----------|---------|-------------|
|
| 161 |
-
| `PORT` | `3000` |
|
| 162 |
-
| `MODEL_URL` | `http://127.0.0.1:8000` |
|
|
|
|
| 1 |
+
# Frontend (Next.js β port 3030)
|
| 2 |
|
| 3 |
+
Ethos Studio UI. Upload audio or video files, view transcription results with per-segment emotion badges and facial emotion (FER) badges, and explore the waveform timeline in the Studio editor.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
|
| 7 |
## Architecture
|
| 8 |
|
| 9 |
```
|
| 10 |
Browser (port 3030)
|
| 11 |
+
β Proxy layer (Node, port 3000) POST /api/speech-to-text, POST /api/transcribe-diarize, GET /health
|
| 12 |
+
β API layer (Python, port 8000) POST /transcribe, POST /transcribe-diarize, GET /health
|
| 13 |
```
|
| 14 |
|
| 15 |
+
- **Frontend** (`web/`): Upload page + Studio editor. Calls the Proxy layer.
|
| 16 |
+
- **Proxy layer** (`proxy/`): Forwards browser requests to the API layer. See [proxy/README.md](../proxy/README.md) for API details.
|
| 17 |
+
- **API layer** (`api/`): Local Voxtral inference + VAD segmentation + emotion + FER. See [api/README.md](../api/README.md) for API details.
|
| 18 |
|
| 19 |
---
|
| 20 |
|
| 21 |
## Startup
|
| 22 |
|
| 23 |
+
### 1. API layer (Python, port 8000)
|
| 24 |
|
| 25 |
+
Requires **Python 3.11+** and **ffmpeg**.
|
| 26 |
|
| 27 |
```bash
|
| 28 |
+
cd api
|
| 29 |
python -m venv .venv
|
| 30 |
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 31 |
pip install -r requirements.txt
|
| 32 |
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
| 33 |
```
|
| 34 |
|
| 35 |
+
On first run the Voxtral model (~8 GB) is downloaded from HuggingFace.
|
| 36 |
|
| 37 |
+
### 2. Proxy layer (Node, port 3000)
|
| 38 |
|
| 39 |
```bash
|
| 40 |
+
cd proxy
|
| 41 |
npm install
|
| 42 |
npm run dev
|
| 43 |
```
|
|
|
|
| 45 |
### 3. Frontend (Next.js, port 3030)
|
| 46 |
|
| 47 |
```bash
|
| 48 |
+
cd web
|
| 49 |
npm install
|
| 50 |
npm run dev
|
| 51 |
```
|
| 52 |
|
| 53 |
Open [http://localhost:3030](http://localhost:3030).
|
| 54 |
|
| 55 |
+
- **Home page**: Click **Transcribe files**, drag-drop an audio or video file, then **Upload**. The file is sent to `/api/transcribe-diarize` and results open in the Studio.
|
| 56 |
+
- **Studio page** (`/studio`): Three-column layout β transcript segments (speaker + emotion badges + FER badges for video) on the left, waveform in the center, audio player on the right.
|
| 57 |
|
| 58 |
### 4. Quick check (API only)
|
| 59 |
|
|
|
|
| 61 |
curl -s http://localhost:3000/health
|
| 62 |
curl -X POST http://localhost:3000/api/speech-to-text -F "audio=@audio.m4a"
|
| 63 |
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@audio.m4a"
|
| 64 |
+
curl -X POST http://localhost:3000/api/transcribe-diarize -F "audio=@video.mov"
|
| 65 |
```
|
| 66 |
|
| 67 |
---
|
| 68 |
|
| 69 |
+
## API (Proxy layer)
|
| 70 |
|
| 71 |
+
Clients should call the **Proxy layer** only. The API layer is internal.
|
| 72 |
|
| 73 |
### POST /api/speech-to-text
|
| 74 |
|
|
|
|
| 78 |
|--|--|
|
| 79 |
| **Content-Type** | `multipart/form-data` |
|
| 80 |
| **Body** | `audio` β audio file (wav, mp3, flac, ogg, m4a, webm) |
|
| 81 |
+
| **Limits** | β€ 100 MB; timeout 30 min |
|
| 82 |
|
| 83 |
**Response (200)**
|
| 84 |
|
|
|
|
| 86 |
{
|
| 87 |
"text": "transcribed text",
|
| 88 |
"words": [],
|
| 89 |
+
"languageCode": "en"
|
| 90 |
}
|
| 91 |
```
|
| 92 |
|
|
|
|
| 94 |
|
| 95 |
### POST /api/transcribe-diarize
|
| 96 |
|
| 97 |
+
Full pipeline: transcription + VAD sentence segmentation + per-segment emotion analysis. For video inputs, also returns `face_emotion` per segment.
|
| 98 |
|
| 99 |
| | |
|
| 100 |
|--|--|
|
| 101 |
| **Content-Type** | `multipart/form-data` |
|
| 102 |
+
| **Body** | `audio` β audio or video file (wav, mp3, flac, ogg, m4a, webm, mp4, mov, mkv) |
|
| 103 |
+
| **Limits** | β€ 100 MB; timeout 60 min |
|
| 104 |
|
| 105 |
**Response (200)**
|
| 106 |
|
|
|
|
| 113 |
"start": 0.0,
|
| 114 |
"end": 4.2,
|
| 115 |
"text": "Hello, how are you?",
|
| 116 |
+
"emotion": "Happy",
|
| 117 |
+
"valence": 0.7,
|
| 118 |
+
"arousal": 0.6,
|
| 119 |
+
"face_emotion": "Happy"
|
| 120 |
}
|
| 121 |
],
|
| 122 |
"duration": 42.3,
|
| 123 |
"text": "full transcript text",
|
| 124 |
+
"filename": "recording.mov",
|
| 125 |
+
"diarization_method": "vad",
|
| 126 |
+
"has_video": true
|
| 127 |
}
|
| 128 |
```
|
| 129 |
|
| 130 |
+
`face_emotion` appears only on video uploads when FER is enabled. `diarization_method` is always `"vad"`.
|
| 131 |
|
| 132 |
---
|
| 133 |
|
|
|
|
| 139 |
"server": "ser-server",
|
| 140 |
"model": {
|
| 141 |
"status": "ok",
|
| 142 |
+
"model": "mistralai/Voxtral-Mini-3B-2507 + YongkangZOU/evoxtral-lora (local)",
|
| 143 |
"model_loaded": true,
|
| 144 |
"ffmpeg": true,
|
| 145 |
+
"fer_enabled": true,
|
| 146 |
+
"device": "cpu",
|
| 147 |
"max_upload_mb": 100
|
| 148 |
}
|
| 149 |
}
|
|
|
|
| 153 |
|
| 154 |
## Environment variables
|
| 155 |
|
| 156 |
+
Create `web/.env.local`:
|
| 157 |
|
| 158 |
| Variable | Default | Description |
|
| 159 |
|----------|---------|-------------|
|
| 160 |
+
| `NEXT_PUBLIC_API_URL` | `http://localhost:3000` | Proxy layer URL used by the browser |
|
| 161 |
|
| 162 |
+
Create `proxy/.env` or export:
|
| 163 |
|
| 164 |
| Variable | Default | Description |
|
| 165 |
|----------|---------|-------------|
|
| 166 |
+
| `PORT` | `3000` | Proxy layer port |
|
| 167 |
+
| `MODEL_URL` | `http://127.0.0.1:8000` | API layer URL |
|
web/components.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
| 1 |
-
{
|
| 2 |
-
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
-
"style": "radix-maia",
|
| 4 |
-
"rsc": true,
|
| 5 |
-
"tsx": true,
|
| 6 |
-
"tailwind": {
|
| 7 |
-
"config": "",
|
| 8 |
-
"css": "src/app/globals.css",
|
| 9 |
-
"baseColor": "neutral",
|
| 10 |
-
"cssVariables": true,
|
| 11 |
-
"prefix": ""
|
| 12 |
-
},
|
| 13 |
-
"iconLibrary": "lucide",
|
| 14 |
-
"rtl": false,
|
| 15 |
-
"aliases": {
|
| 16 |
-
"components": "@/components",
|
| 17 |
-
"utils": "@/lib/utils",
|
| 18 |
-
"ui": "@/components/ui",
|
| 19 |
-
"lib": "@/lib",
|
| 20 |
-
"hooks": "@/hooks"
|
| 21 |
-
},
|
| 22 |
-
"registries": {}
|
| 23 |
}
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "radix-maia",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"iconLibrary": "lucide",
|
| 14 |
+
"rtl": false,
|
| 15 |
+
"aliases": {
|
| 16 |
+
"components": "@/components",
|
| 17 |
+
"utils": "@/lib/utils",
|
| 18 |
+
"ui": "@/components/ui",
|
| 19 |
+
"lib": "@/lib",
|
| 20 |
+
"hooks": "@/hooks"
|
| 21 |
+
},
|
| 22 |
+
"registries": {}
|
| 23 |
}
|
web/eslint.config.mjs
CHANGED
|
@@ -1,18 +1,18 @@
|
|
| 1 |
-
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
-
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
-
import nextTs from "eslint-config-next/typescript";
|
| 4 |
-
|
| 5 |
-
const eslintConfig = defineConfig([
|
| 6 |
-
...nextVitals,
|
| 7 |
-
...nextTs,
|
| 8 |
-
// Override default ignores of eslint-config-next.
|
| 9 |
-
globalIgnores([
|
| 10 |
-
// Default ignores of eslint-config-next:
|
| 11 |
-
".next/**",
|
| 12 |
-
"out/**",
|
| 13 |
-
"build/**",
|
| 14 |
-
"next-env.d.ts",
|
| 15 |
-
]),
|
| 16 |
-
]);
|
| 17 |
-
|
| 18 |
-
export default eslintConfig;
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
import nextTs from "eslint-config-next/typescript";
|
| 4 |
+
|
| 5 |
+
const eslintConfig = defineConfig([
|
| 6 |
+
...nextVitals,
|
| 7 |
+
...nextTs,
|
| 8 |
+
// Override default ignores of eslint-config-next.
|
| 9 |
+
globalIgnores([
|
| 10 |
+
// Default ignores of eslint-config-next:
|
| 11 |
+
".next/**",
|
| 12 |
+
"out/**",
|
| 13 |
+
"build/**",
|
| 14 |
+
"next-env.d.ts",
|
| 15 |
+
]),
|
| 16 |
+
]);
|
| 17 |
+
|
| 18 |
+
export default eslintConfig;
|
web/next.config.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
| 1 |
-
import type { NextConfig } from "next";
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
import path from "path";
|
| 3 |
+
|
| 4 |
+
const nextConfig: NextConfig = {
|
| 5 |
+
turbopack: {
|
| 6 |
+
root: path.resolve(__dirname),
|
| 7 |
+
},
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default nextConfig;
|
web/package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/package.json
CHANGED
|
@@ -1,37 +1,38 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "demo",
|
| 3 |
-
"version": "0.1.0",
|
| 4 |
-
"private": true,
|
| 5 |
-
"scripts": {
|
| 6 |
-
"dev": "next dev -p 3030",
|
| 7 |
-
"build": "next build",
|
| 8 |
-
"start": "next start",
|
| 9 |
-
"lint": "eslint"
|
| 10 |
-
},
|
| 11 |
-
"dependencies": {
|
| 12 |
-
"@fontsource/jetbrains-mono": "^5.2.8",
|
| 13 |
-
"@fontsource/lato": "^5.2.7",
|
| 14 |
-
"@phosphor-icons/react": "^2.1.10",
|
| 15 |
-
"class-variance-authority": "^0.7.1",
|
| 16 |
-
"clsx": "^2.1.1",
|
| 17 |
-
"framer-motion": "^12.34.3",
|
| 18 |
-
"lucide-react": "^0.575.0",
|
| 19 |
-
"next": "16.1.6",
|
| 20 |
-
"radix-ui": "^1.4.3",
|
| 21 |
-
"react": "19.2.3",
|
| 22 |
-
"react-dom": "19.2.3",
|
| 23 |
-
"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
"@
|
| 28 |
-
"@types/
|
| 29 |
-
"@types/react
|
| 30 |
-
"
|
| 31 |
-
"eslint
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
| 36 |
-
|
| 37 |
-
}
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "demo",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 3030",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"@fontsource/jetbrains-mono": "^5.2.8",
|
| 13 |
+
"@fontsource/lato": "^5.2.7",
|
| 14 |
+
"@phosphor-icons/react": "^2.1.10",
|
| 15 |
+
"class-variance-authority": "^0.7.1",
|
| 16 |
+
"clsx": "^2.1.1",
|
| 17 |
+
"framer-motion": "^12.34.3",
|
| 18 |
+
"lucide-react": "^0.575.0",
|
| 19 |
+
"next": "16.1.6",
|
| 20 |
+
"radix-ui": "^1.4.3",
|
| 21 |
+
"react": "19.2.3",
|
| 22 |
+
"react-dom": "19.2.3",
|
| 23 |
+
"recharts": "^3.7.0",
|
| 24 |
+
"tailwind-merge": "^3.5.0"
|
| 25 |
+
},
|
| 26 |
+
"devDependencies": {
|
| 27 |
+
"@tailwindcss/postcss": "^4",
|
| 28 |
+
"@types/node": "^20",
|
| 29 |
+
"@types/react": "^19",
|
| 30 |
+
"@types/react-dom": "^19",
|
| 31 |
+
"eslint": "^9",
|
| 32 |
+
"eslint-config-next": "16.1.6",
|
| 33 |
+
"shadcn": "^3.8.5",
|
| 34 |
+
"tailwindcss": "^4",
|
| 35 |
+
"tw-animate-css": "^1.4.0",
|
| 36 |
+
"typescript": "^5"
|
| 37 |
+
}
|
| 38 |
+
}
|
web/postcss.config.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
const config = {
|
| 2 |
-
plugins: {
|
| 3 |
-
"@tailwindcss/postcss": {},
|
| 4 |
-
},
|
| 5 |
-
};
|
| 6 |
-
|
| 7 |
-
export default config;
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
web/public/file.svg
DELETED
web/public/globe.svg
DELETED
web/public/logo-black-bg.svg
ADDED
|
|
web/public/logo.svg
ADDED
|
|
web/public/next.svg
DELETED
web/public/vercel.svg
DELETED
web/public/window.svg
DELETED
web/src/app/api/speech-to-text/route.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export async function POST(req: Request) {
|
| 4 |
+
try {
|
| 5 |
+
const formData = await req.formData();
|
| 6 |
+
const audioFile = formData.get("audio") as Blob;
|
| 7 |
+
|
| 8 |
+
if (!audioFile) {
|
| 9 |
+
return NextResponse.json(
|
| 10 |
+
{ error: "Audio file is required" },
|
| 11 |
+
{ status: 400 }
|
| 12 |
+
);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
| 16 |
+
if (audioFile.size > MAX_UPLOAD_BYTES) {
|
| 17 |
+
return NextResponse.json(
|
| 18 |
+
{ error: `File size exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024}MB limit` },
|
| 19 |
+
{ status: 413 }
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const MODEL_URL = process.env.MODEL_URL || "http://127.0.0.1:8000";
|
| 24 |
+
|
| 25 |
+
// Forward the formData to the Python backend
|
| 26 |
+
const response = await fetch(`${MODEL_URL}/transcribe`, {
|
| 27 |
+
method: "POST",
|
| 28 |
+
body: formData,
|
| 29 |
+
// Signal timeout after 5 minutes
|
| 30 |
+
signal: AbortSignal.timeout(5 * 60 * 1000),
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
if (!response.ok) {
|
| 34 |
+
const errorData = await response.json().catch(() => ({}));
|
| 35 |
+
return NextResponse.json(
|
| 36 |
+
{ error: errorData.detail || "Transcription failed at Model layer" },
|
| 37 |
+
{ status: response.status }
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const data = await response.json();
|
| 42 |
+
return NextResponse.json(data);
|
| 43 |
+
} catch (error: any) {
|
| 44 |
+
console.error("Transcription proxy error:", error);
|
| 45 |
+
const isTimeout = error.name === "TimeoutError" || error.name === "AbortError";
|
| 46 |
+
return NextResponse.json(
|
| 47 |
+
{ error: isTimeout ? "Transcription timed out" : "Internal server error connecting to model layer" },
|
| 48 |
+
{ status: isTimeout ? 504 : 500 }
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
}
|
web/src/app/favicon.ico
CHANGED
|
|
|
|
web/src/app/globals.css
CHANGED
|
@@ -1,149 +1,149 @@
|
|
| 1 |
-
@import "tailwindcss";
|
| 2 |
-
@import "tw-animate-css";
|
| 3 |
-
@import "shadcn/tailwind.css";
|
| 4 |
-
|
| 5 |
-
@custom-variant dark (&:is(.dark *));
|
| 6 |
-
|
| 7 |
-
@theme inline {
|
| 8 |
-
--color-background: var(--background);
|
| 9 |
-
--color-foreground: var(--foreground);
|
| 10 |
-
--font-sans: var(--font-lato);
|
| 11 |
-
--font-mono: var(--font-lato);
|
| 12 |
-
--color-sidebar-ring: var(--sidebar-ring);
|
| 13 |
-
--color-sidebar-border: var(--sidebar-border);
|
| 14 |
-
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 15 |
-
--color-sidebar-accent: var(--sidebar-accent);
|
| 16 |
-
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 17 |
-
--color-sidebar-primary: var(--sidebar-primary);
|
| 18 |
-
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 19 |
-
--color-sidebar: var(--sidebar);
|
| 20 |
-
--color-chart-5: var(--chart-5);
|
| 21 |
-
--color-chart-4: var(--chart-4);
|
| 22 |
-
--color-chart-3: var(--chart-3);
|
| 23 |
-
--color-chart-2: var(--chart-2);
|
| 24 |
-
--color-chart-1: var(--chart-1);
|
| 25 |
-
--color-ring: var(--ring);
|
| 26 |
-
--color-input: var(--input);
|
| 27 |
-
--color-border: var(--border);
|
| 28 |
-
--color-destructive: var(--destructive);
|
| 29 |
-
--color-accent-foreground: var(--accent-foreground);
|
| 30 |
-
--color-accent: var(--accent);
|
| 31 |
-
--color-muted-foreground: var(--muted-foreground);
|
| 32 |
-
--color-muted: var(--muted);
|
| 33 |
-
--color-secondary-foreground: var(--secondary-foreground);
|
| 34 |
-
--color-secondary: var(--secondary);
|
| 35 |
-
--color-primary-foreground: var(--primary-foreground);
|
| 36 |
-
--color-primary: var(--primary);
|
| 37 |
-
--color-popover-foreground: var(--popover-foreground);
|
| 38 |
-
--color-popover: var(--popover);
|
| 39 |
-
--color-card-foreground: var(--card-foreground);
|
| 40 |
-
--color-card: var(--card);
|
| 41 |
-
--radius-sm: calc(var(--radius) - 4px);
|
| 42 |
-
--radius-md: calc(var(--radius) - 2px);
|
| 43 |
-
--radius-lg: var(--radius);
|
| 44 |
-
--radius-xl: calc(var(--radius) + 4px);
|
| 45 |
-
--radius-2xl: calc(var(--radius) + 8px);
|
| 46 |
-
--radius-3xl: calc(var(--radius) + 12px);
|
| 47 |
-
--radius-4xl: calc(var(--radius) + 16px);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
:root {
|
| 51 |
-
--radius: 0.
|
| 52 |
-
|
| 53 |
-
/* Background / Surface */
|
| 54 |
-
--background: oklch(0.985 0 0);
|
| 55 |
-
--foreground: oklch(0.1 0 0);
|
| 56 |
-
|
| 57 |
-
/* Card */
|
| 58 |
-
--card: oklch(1 0 0);
|
| 59 |
-
--card-foreground: oklch(0.1 0 0);
|
| 60 |
-
|
| 61 |
-
/* Popover */
|
| 62 |
-
--popover: oklch(1 0 0);
|
| 63 |
-
--popover-foreground: oklch(0.1 0 0);
|
| 64 |
-
|
| 65 |
-
/* Primary = Black */
|
| 66 |
-
--primary: oklch(0.1 0 0);
|
| 67 |
-
--primary-foreground: oklch(1 0 0);
|
| 68 |
-
|
| 69 |
-
/* Secondary = Light gray */
|
| 70 |
-
--secondary: oklch(0.94 0 0);
|
| 71 |
-
--secondary-foreground: oklch(0.2 0 0);
|
| 72 |
-
|
| 73 |
-
/* Muted = Subtle surfaces */
|
| 74 |
-
--muted: oklch(0.94 0 0);
|
| 75 |
-
--muted-foreground: oklch(0.55 0 0);
|
| 76 |
-
|
| 77 |
-
/* Accent */
|
| 78 |
-
--accent: oklch(0.92 0 0);
|
| 79 |
-
--accent-foreground: oklch(0.1 0 0);
|
| 80 |
-
|
| 81 |
-
/* Destructive */
|
| 82 |
-
--destructive: oklch(0.577 0.245 27.325);
|
| 83 |
-
|
| 84 |
-
/* Border and Input */
|
| 85 |
-
--border: oklch(0.88 0 0);
|
| 86 |
-
--input: oklch(0.88 0 0);
|
| 87 |
-
--ring: oklch(0.6 0 0);
|
| 88 |
-
|
| 89 |
-
/* Charts */
|
| 90 |
-
--chart-1: oklch(0.3 0 0);
|
| 91 |
-
--chart-2: oklch(0.55 0 0);
|
| 92 |
-
--chart-3: oklch(0.45 0.07 227.392);
|
| 93 |
-
--chart-4: oklch(0.7 0.1 84.429);
|
| 94 |
-
--chart-5: oklch(0.5 0.15 16.439);
|
| 95 |
-
|
| 96 |
-
/* Sidebar */
|
| 97 |
-
--sidebar: oklch(0.97 0 0);
|
| 98 |
-
--sidebar-foreground: oklch(0.1 0 0);
|
| 99 |
-
--sidebar-primary: oklch(0.1 0 0);
|
| 100 |
-
--sidebar-primary-foreground: oklch(1 0 0);
|
| 101 |
-
--sidebar-accent: oklch(0.92 0 0);
|
| 102 |
-
--sidebar-accent-foreground: oklch(0.1 0 0);
|
| 103 |
-
--sidebar-border: oklch(0.88 0 0);
|
| 104 |
-
--sidebar-ring: oklch(0.6 0 0);
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
.dark {
|
| 108 |
-
--background: oklch(0.1 0 0);
|
| 109 |
-
--foreground: oklch(0.97 0 0);
|
| 110 |
-
--card: oklch(0.15 0 0);
|
| 111 |
-
--card-foreground: oklch(0.97 0 0);
|
| 112 |
-
--popover: oklch(0.15 0 0);
|
| 113 |
-
--popover-foreground: oklch(0.97 0 0);
|
| 114 |
-
--primary: oklch(0.97 0 0);
|
| 115 |
-
--primary-foreground: oklch(0.1 0 0);
|
| 116 |
-
--secondary: oklch(0.22 0 0);
|
| 117 |
-
--secondary-foreground: oklch(0.97 0 0);
|
| 118 |
-
--muted: oklch(0.22 0 0);
|
| 119 |
-
--muted-foreground: oklch(0.62 0 0);
|
| 120 |
-
--accent: oklch(0.22 0 0);
|
| 121 |
-
--accent-foreground: oklch(0.97 0 0);
|
| 122 |
-
--destructive: oklch(0.704 0.191 22.216);
|
| 123 |
-
--border: oklch(1 0 0 / 10%);
|
| 124 |
-
--input: oklch(1 0 0 / 15%);
|
| 125 |
-
--ring: oklch(0.5 0 0);
|
| 126 |
-
--chart-1: oklch(0.8 0 0);
|
| 127 |
-
--chart-2: oklch(0.6 0 0);
|
| 128 |
-
--chart-3: oklch(0.7 0.1 227.392);
|
| 129 |
-
--chart-4: oklch(0.6 0.2 303.9);
|
| 130 |
-
--chart-5: oklch(0.6 0.2 16.439);
|
| 131 |
-
--sidebar: oklch(0.15 0 0);
|
| 132 |
-
--sidebar-foreground: oklch(0.97 0 0);
|
| 133 |
-
--sidebar-primary: oklch(0.97 0 0);
|
| 134 |
-
--sidebar-primary-foreground: oklch(0.1 0 0);
|
| 135 |
-
--sidebar-accent: oklch(0.22 0 0);
|
| 136 |
-
--sidebar-accent-foreground: oklch(0.97 0 0);
|
| 137 |
-
--sidebar-border: oklch(1 0 0 / 10%);
|
| 138 |
-
--sidebar-ring: oklch(0.5 0 0);
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
@layer base {
|
| 142 |
-
* {
|
| 143 |
-
@apply border-border outline-ring/50;
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
body {
|
| 147 |
-
@apply bg-background text-foreground;
|
| 148 |
-
}
|
| 149 |
}
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
@import "shadcn/tailwind.css";
|
| 4 |
+
|
| 5 |
+
@custom-variant dark (&:is(.dark *));
|
| 6 |
+
|
| 7 |
+
@theme inline {
|
| 8 |
+
--color-background: var(--background);
|
| 9 |
+
--color-foreground: var(--foreground);
|
| 10 |
+
--font-sans: var(--font-lato);
|
| 11 |
+
--font-mono: var(--font-lato);
|
| 12 |
+
--color-sidebar-ring: var(--sidebar-ring);
|
| 13 |
+
--color-sidebar-border: var(--sidebar-border);
|
| 14 |
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
| 15 |
+
--color-sidebar-accent: var(--sidebar-accent);
|
| 16 |
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
| 17 |
+
--color-sidebar-primary: var(--sidebar-primary);
|
| 18 |
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
| 19 |
+
--color-sidebar: var(--sidebar);
|
| 20 |
+
--color-chart-5: var(--chart-5);
|
| 21 |
+
--color-chart-4: var(--chart-4);
|
| 22 |
+
--color-chart-3: var(--chart-3);
|
| 23 |
+
--color-chart-2: var(--chart-2);
|
| 24 |
+
--color-chart-1: var(--chart-1);
|
| 25 |
+
--color-ring: var(--ring);
|
| 26 |
+
--color-input: var(--input);
|
| 27 |
+
--color-border: var(--border);
|
| 28 |
+
--color-destructive: var(--destructive);
|
| 29 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 30 |
+
--color-accent: var(--accent);
|
| 31 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 32 |
+
--color-muted: var(--muted);
|
| 33 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 34 |
+
--color-secondary: var(--secondary);
|
| 35 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 36 |
+
--color-primary: var(--primary);
|
| 37 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 38 |
+
--color-popover: var(--popover);
|
| 39 |
+
--color-card-foreground: var(--card-foreground);
|
| 40 |
+
--color-card: var(--card);
|
| 41 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 42 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 43 |
+
--radius-lg: var(--radius);
|
| 44 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 45 |
+
--radius-2xl: calc(var(--radius) + 8px);
|
| 46 |
+
--radius-3xl: calc(var(--radius) + 12px);
|
| 47 |
+
--radius-4xl: calc(var(--radius) + 16px);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
:root {
|
| 51 |
+
--radius: 0.375rem;
|
| 52 |
+
|
| 53 |
+
/* Background / Surface */
|
| 54 |
+
--background: oklch(0.985 0 0);
|
| 55 |
+
--foreground: oklch(0.1 0 0);
|
| 56 |
+
|
| 57 |
+
/* Card */
|
| 58 |
+
--card: oklch(1 0 0);
|
| 59 |
+
--card-foreground: oklch(0.1 0 0);
|
| 60 |
+
|
| 61 |
+
/* Popover */
|
| 62 |
+
--popover: oklch(1 0 0);
|
| 63 |
+
--popover-foreground: oklch(0.1 0 0);
|
| 64 |
+
|
| 65 |
+
/* Primary = Black */
|
| 66 |
+
--primary: oklch(0.1 0 0);
|
| 67 |
+
--primary-foreground: oklch(1 0 0);
|
| 68 |
+
|
| 69 |
+
/* Secondary = Light gray */
|
| 70 |
+
--secondary: oklch(0.94 0 0);
|
| 71 |
+
--secondary-foreground: oklch(0.2 0 0);
|
| 72 |
+
|
| 73 |
+
/* Muted = Subtle surfaces */
|
| 74 |
+
--muted: oklch(0.94 0 0);
|
| 75 |
+
--muted-foreground: oklch(0.55 0 0);
|
| 76 |
+
|
| 77 |
+
/* Accent */
|
| 78 |
+
--accent: oklch(0.92 0 0);
|
| 79 |
+
--accent-foreground: oklch(0.1 0 0);
|
| 80 |
+
|
| 81 |
+
/* Destructive */
|
| 82 |
+
--destructive: oklch(0.577 0.245 27.325);
|
| 83 |
+
|
| 84 |
+
/* Border and Input */
|
| 85 |
+
--border: oklch(0.88 0 0);
|
| 86 |
+
--input: oklch(0.88 0 0);
|
| 87 |
+
--ring: oklch(0.6 0 0);
|
| 88 |
+
|
| 89 |
+
/* Charts */
|
| 90 |
+
--chart-1: oklch(0.3 0 0);
|
| 91 |
+
--chart-2: oklch(0.55 0 0);
|
| 92 |
+
--chart-3: oklch(0.45 0.07 227.392);
|
| 93 |
+
--chart-4: oklch(0.7 0.1 84.429);
|
| 94 |
+
--chart-5: oklch(0.5 0.15 16.439);
|
| 95 |
+
|
| 96 |
+
/* Sidebar */
|
| 97 |
+
--sidebar: oklch(0.97 0 0);
|
| 98 |
+
--sidebar-foreground: oklch(0.1 0 0);
|
| 99 |
+
--sidebar-primary: oklch(0.1 0 0);
|
| 100 |
+
--sidebar-primary-foreground: oklch(1 0 0);
|
| 101 |
+
--sidebar-accent: oklch(0.92 0 0);
|
| 102 |
+
--sidebar-accent-foreground: oklch(0.1 0 0);
|
| 103 |
+
--sidebar-border: oklch(0.88 0 0);
|
| 104 |
+
--sidebar-ring: oklch(0.6 0 0);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.dark {
|
| 108 |
+
--background: oklch(0.1 0 0);
|
| 109 |
+
--foreground: oklch(0.97 0 0);
|
| 110 |
+
--card: oklch(0.15 0 0);
|
| 111 |
+
--card-foreground: oklch(0.97 0 0);
|
| 112 |
+
--popover: oklch(0.15 0 0);
|
| 113 |
+
--popover-foreground: oklch(0.97 0 0);
|
| 114 |
+
--primary: oklch(0.97 0 0);
|
| 115 |
+
--primary-foreground: oklch(0.1 0 0);
|
| 116 |
+
--secondary: oklch(0.22 0 0);
|
| 117 |
+
--secondary-foreground: oklch(0.97 0 0);
|
| 118 |
+
--muted: oklch(0.22 0 0);
|
| 119 |
+
--muted-foreground: oklch(0.62 0 0);
|
| 120 |
+
--accent: oklch(0.22 0 0);
|
| 121 |
+
--accent-foreground: oklch(0.97 0 0);
|
| 122 |
+
--destructive: oklch(0.704 0.191 22.216);
|
| 123 |
+
--border: oklch(1 0 0 / 10%);
|
| 124 |
+
--input: oklch(1 0 0 / 15%);
|
| 125 |
+
--ring: oklch(0.5 0 0);
|
| 126 |
+
--chart-1: oklch(0.8 0 0);
|
| 127 |
+
--chart-2: oklch(0.6 0 0);
|
| 128 |
+
--chart-3: oklch(0.7 0.1 227.392);
|
| 129 |
+
--chart-4: oklch(0.6 0.2 303.9);
|
| 130 |
+
--chart-5: oklch(0.6 0.2 16.439);
|
| 131 |
+
--sidebar: oklch(0.15 0 0);
|
| 132 |
+
--sidebar-foreground: oklch(0.97 0 0);
|
| 133 |
+
--sidebar-primary: oklch(0.97 0 0);
|
| 134 |
+
--sidebar-primary-foreground: oklch(0.1 0 0);
|
| 135 |
+
--sidebar-accent: oklch(0.22 0 0);
|
| 136 |
+
--sidebar-accent-foreground: oklch(0.97 0 0);
|
| 137 |
+
--sidebar-border: oklch(1 0 0 / 10%);
|
| 138 |
+
--sidebar-ring: oklch(0.5 0 0);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
@layer base {
|
| 142 |
+
* {
|
| 143 |
+
@apply border-border outline-ring/50;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
body {
|
| 147 |
+
@apply bg-background text-foreground;
|
| 148 |
+
}
|
| 149 |
}
|
web/src/app/layout.tsx
CHANGED
|
@@ -1,33 +1,43 @@
|
|
| 1 |
-
import type { Metadata } from "next";
|
| 2 |
-
import { Lato } from "next/font/google";
|
| 3 |
-
import "
|
| 4 |
-
import
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Lato } from "next/font/google";
|
| 3 |
+
import { Suspense } from "react";
|
| 4 |
+
import "./globals.css";
|
| 5 |
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
| 6 |
+
import { SidebarProvider } from "@/components/ui/sidebar";
|
| 7 |
+
import { AppSidebar } from "@/components/sidebar";
|
| 8 |
+
|
| 9 |
+
const lato = Lato({
|
| 10 |
+
weight: ["100", "300", "400", "700", "900"],
|
| 11 |
+
variable: "--font-lato",
|
| 12 |
+
subsets: ["latin"],
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata: Metadata = {
|
| 16 |
+
title: "Ethos Studio | Emotional Speech Recognition",
|
| 17 |
+
description: "Advanced emotional speech recognition and transcription studio powered by Evoxtral.",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({
|
| 21 |
+
children,
|
| 22 |
+
}: Readonly<{
|
| 23 |
+
children: React.ReactNode;
|
| 24 |
+
}>) {
|
| 25 |
+
return (
|
| 26 |
+
<html lang="en">
|
| 27 |
+
<body
|
| 28 |
+
className={`${lato.variable} antialiased selection:bg-black/10 font-sans`}
|
| 29 |
+
>
|
| 30 |
+
<TooltipProvider>
|
| 31 |
+
<SidebarProvider>
|
| 32 |
+
<Suspense fallback={<div className="w-64 flex-shrink-0" />}>
|
| 33 |
+
<AppSidebar />
|
| 34 |
+
</Suspense>
|
| 35 |
+
<main className="flex-1 flex flex-col min-w-0 min-h-screen">
|
| 36 |
+
{children}
|
| 37 |
+
</main>
|
| 38 |
+
</SidebarProvider>
|
| 39 |
+
</TooltipProvider>
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
| 42 |
+
);
|
| 43 |
+
}
|
web/src/app/page.tsx
CHANGED
|
@@ -1,369 +1,180 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import React, { useState } from "react"
|
| 4 |
-
import
|
| 5 |
-
import {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
} from "@
|
| 10 |
-
import {
|
| 11 |
-
import {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
const
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
<
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
<p className="text-sm text-destructive font-medium">{error}</p>
|
| 182 |
-
)}
|
| 183 |
-
|
| 184 |
-
<Separator />
|
| 185 |
-
|
| 186 |
-
{/* Settings */}
|
| 187 |
-
<div className="space-y-4">
|
| 188 |
-
<div className="flex items-center justify-between">
|
| 189 |
-
<Label htmlFor="lang-select" className="text-sm font-medium">Primary language</Label>
|
| 190 |
-
<Select defaultValue="detect">
|
| 191 |
-
<SelectTrigger id="lang-select" className="w-32 h-8 text-sm">
|
| 192 |
-
<SelectValue placeholder="Detect" />
|
| 193 |
-
</SelectTrigger>
|
| 194 |
-
<SelectContent>
|
| 195 |
-
<SelectItem value="detect">Detect</SelectItem>
|
| 196 |
-
<SelectItem value="en">English</SelectItem>
|
| 197 |
-
<SelectItem value="zh">Chinese</SelectItem>
|
| 198 |
-
<SelectItem value="es">Spanish</SelectItem>
|
| 199 |
-
<SelectItem value="fr">French</SelectItem>
|
| 200 |
-
</SelectContent>
|
| 201 |
-
</Select>
|
| 202 |
-
</div>
|
| 203 |
-
|
| 204 |
-
<div className="flex items-center justify-between">
|
| 205 |
-
<Label htmlFor="diarize" className="text-sm font-medium">Speaker diarization</Label>
|
| 206 |
-
<Switch id="diarize" defaultChecked disabled />
|
| 207 |
-
</div>
|
| 208 |
-
|
| 209 |
-
<div className="flex items-center justify-between">
|
| 210 |
-
<Label htmlFor="emotion" className="text-sm font-medium">Emotion analysis</Label>
|
| 211 |
-
<Switch id="emotion" defaultChecked />
|
| 212 |
-
</div>
|
| 213 |
-
|
| 214 |
-
<div className="flex items-center justify-between">
|
| 215 |
-
<Label htmlFor="subtitles" className="text-sm font-medium">Include subtitles</Label>
|
| 216 |
-
<Switch id="subtitles" />
|
| 217 |
-
</div>
|
| 218 |
-
</div>
|
| 219 |
-
|
| 220 |
-
<Button
|
| 221 |
-
className="w-full gap-2 font-bold"
|
| 222 |
-
onClick={handleUpload}
|
| 223 |
-
disabled={loading || !file}
|
| 224 |
-
>
|
| 225 |
-
{loading ? (
|
| 226 |
-
<>
|
| 227 |
-
<div className="w-4 h-4 border-2 border-primary-foreground/40 border-t-primary-foreground rounded-full animate-spin" />
|
| 228 |
-
{progress || "Processingβ¦"}
|
| 229 |
-
</>
|
| 230 |
-
) : (
|
| 231 |
-
<>
|
| 232 |
-
<UploadSimple size={16} weight="bold" />
|
| 233 |
-
Upload & transcribe
|
| 234 |
-
</>
|
| 235 |
-
)}
|
| 236 |
-
</Button>
|
| 237 |
-
</DialogContent>
|
| 238 |
-
</Dialog>
|
| 239 |
-
)
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
// --- Main Page ---
|
| 243 |
-
export default function HomePage() {
|
| 244 |
-
const [showModal, setShowModal] = useState(false)
|
| 245 |
-
const [search, setSearch] = useState("")
|
| 246 |
-
|
| 247 |
-
const filtered = MOCK_SESSIONS.filter((s) =>
|
| 248 |
-
s.title.toLowerCase().includes(search.toLowerCase())
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
-
return (
|
| 252 |
-
<div className="min-h-screen bg-background text-foreground">
|
| 253 |
-
<Navbar />
|
| 254 |
-
|
| 255 |
-
<main className="max-w-4xl mx-auto px-6 py-10">
|
| 256 |
-
{/* Page Header */}
|
| 257 |
-
<div className="flex items-start justify-between mb-6">
|
| 258 |
-
<div>
|
| 259 |
-
<h1 className="text-2xl font-black tracking-tight">Speech to text</h1>
|
| 260 |
-
<p className="text-sm text-muted-foreground mt-1">
|
| 261 |
-
Transcribe audio and video files with our{" "}
|
| 262 |
-
<span className="underline underline-offset-2 text-foreground font-medium cursor-pointer">
|
| 263 |
-
industry-leading ASR model.
|
| 264 |
-
</span>
|
| 265 |
-
</p>
|
| 266 |
-
</div>
|
| 267 |
-
<Button onClick={() => setShowModal(true)} className="gap-2 font-bold shadow-sm">
|
| 268 |
-
<Microphone size={16} weight="bold" />
|
| 269 |
-
Transcribe files
|
| 270 |
-
</Button>
|
| 271 |
-
</div>
|
| 272 |
-
|
| 273 |
-
{/* Promo Banner */}
|
| 274 |
-
<div className="border border-border rounded-lg p-4 flex items-center gap-4 mb-5 bg-card hover:bg-muted/40 transition-colors cursor-pointer">
|
| 275 |
-
<Skeleton className="w-14 h-14 rounded-lg bg-foreground/10 flex-shrink-0" />
|
| 276 |
-
<div className="flex-1 min-w-0">
|
| 277 |
-
<p className="text-sm font-bold">Try Ethostral Realtime</p>
|
| 278 |
-
<p className="text-sm text-muted-foreground mt-0.5 leading-snug">
|
| 279 |
-
Experience lightning-fast transcription with unmatched emotional accuracy, powered by Ethostral.
|
| 280 |
-
</p>
|
| 281 |
-
</div>
|
| 282 |
-
<Button variant="outline" size="sm" className="flex-shrink-0 font-bold">
|
| 283 |
-
Try the demo
|
| 284 |
-
</Button>
|
| 285 |
-
</div>
|
| 286 |
-
|
| 287 |
-
{/* Search */}
|
| 288 |
-
<div className="relative mb-5">
|
| 289 |
-
<MagnifyingGlass
|
| 290 |
-
size={15}
|
| 291 |
-
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
| 292 |
-
/>
|
| 293 |
-
<Input
|
| 294 |
-
placeholder="Search transcripts..."
|
| 295 |
-
value={search}
|
| 296 |
-
onChange={(e) => setSearch(e.target.value)}
|
| 297 |
-
className="pl-9 h-9 text-sm bg-card"
|
| 298 |
-
/>
|
| 299 |
-
</div>
|
| 300 |
-
|
| 301 |
-
{/* Table */}
|
| 302 |
-
<Table>
|
| 303 |
-
<TableHeader>
|
| 304 |
-
<TableRow>
|
| 305 |
-
<TableHead className="text-[11px] font-black uppercase tracking-widest text-muted-foreground">
|
| 306 |
-
Title
|
| 307 |
-
</TableHead>
|
| 308 |
-
<TableHead className="text-[11px] font-black uppercase tracking-widest text-muted-foreground">
|
| 309 |
-
Created at
|
| 310 |
-
</TableHead>
|
| 311 |
-
<TableHead className="w-10" />
|
| 312 |
-
</TableRow>
|
| 313 |
-
</TableHeader>
|
| 314 |
-
<TableBody>
|
| 315 |
-
{filtered.length === 0 ? (
|
| 316 |
-
<TableRow>
|
| 317 |
-
<TableCell colSpan={3} className="text-center text-muted-foreground py-16 text-sm">
|
| 318 |
-
No transcripts found.
|
| 319 |
-
</TableCell>
|
| 320 |
-
</TableRow>
|
| 321 |
-
) : (
|
| 322 |
-
filtered.map((session) => (
|
| 323 |
-
<TableRow key={session.id} className="cursor-pointer group">
|
| 324 |
-
<TableCell>
|
| 325 |
-
{/* Demo sessions link to studio with ?demo=1 (shows mock data) */}
|
| 326 |
-
<Link href="/studio?demo=1" className="flex items-center gap-3">
|
| 327 |
-
<div className="w-8 h-8 rounded-md bg-muted border border-border flex items-center justify-center flex-shrink-0">
|
| 328 |
-
<Play size={12} weight="fill" className="text-muted-foreground" />
|
| 329 |
-
</div>
|
| 330 |
-
<span className="text-sm font-semibold truncate max-w-xs">
|
| 331 |
-
{session.title}
|
| 332 |
-
</span>
|
| 333 |
-
</Link>
|
| 334 |
-
</TableCell>
|
| 335 |
-
<TableCell>
|
| 336 |
-
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
| 337 |
-
<Clock size={13} />
|
| 338 |
-
{session.createdAt}
|
| 339 |
-
</span>
|
| 340 |
-
</TableCell>
|
| 341 |
-
<TableCell>
|
| 342 |
-
<DropdownMenu>
|
| 343 |
-
<DropdownMenuTrigger asChild>
|
| 344 |
-
<Button
|
| 345 |
-
variant="ghost"
|
| 346 |
-
size="icon"
|
| 347 |
-
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
| 348 |
-
>
|
| 349 |
-
<DotsThreeVertical size={16} />
|
| 350 |
-
</Button>
|
| 351 |
-
</DropdownMenuTrigger>
|
| 352 |
-
<DropdownMenuContent align="end">
|
| 353 |
-
<DropdownMenuItem>Open</DropdownMenuItem>
|
| 354 |
-
<DropdownMenuItem>Export</DropdownMenuItem>
|
| 355 |
-
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
|
| 356 |
-
</DropdownMenuContent>
|
| 357 |
-
</DropdownMenu>
|
| 358 |
-
</TableCell>
|
| 359 |
-
</TableRow>
|
| 360 |
-
))
|
| 361 |
-
)}
|
| 362 |
-
</TableBody>
|
| 363 |
-
</Table>
|
| 364 |
-
</main>
|
| 365 |
-
|
| 366 |
-
<UploadDialog open={showModal} onOpenChange={setShowModal} />
|
| 367 |
-
</div>
|
| 368 |
-
)
|
| 369 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef } from "react"
|
| 4 |
+
import { useRouter } from "next/navigation"
|
| 5 |
+
import {
|
| 6 |
+
UploadSimple, Microphone, VideoCamera, Waveform, CheckCircle, X,
|
| 7 |
+
} from "@phosphor-icons/react"
|
| 8 |
+
import { Button } from "@/components/ui/button"
|
| 9 |
+
import { Navbar } from "@/components/navbar"
|
| 10 |
+
import { createPendingSession, type DiarizeResult } from "@/lib/session-store"
|
| 11 |
+
import { cn } from "@/lib/utils"
|
| 12 |
+
|
| 13 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"
|
| 14 |
+
const MAX_FILE_BYTES = 100 * 1024 * 1024
|
| 15 |
+
|
| 16 |
+
export default function CreatePage() {
|
| 17 |
+
const router = useRouter()
|
| 18 |
+
const inputRef = useRef<HTMLInputElement>(null)
|
| 19 |
+
|
| 20 |
+
const [file, setFile] = useState<File | null>(null)
|
| 21 |
+
const [dragging, setDragging] = useState(false)
|
| 22 |
+
const [loading, setLoading] = useState(false)
|
| 23 |
+
const [progress, setProgress] = useState("")
|
| 24 |
+
const [error, setError] = useState<string | null>(null)
|
| 25 |
+
|
| 26 |
+
const isValidFile = (f: File) =>
|
| 27 |
+
f.type.startsWith("audio/") ||
|
| 28 |
+
f.type.startsWith("video/") ||
|
| 29 |
+
/\.(wav|mp3|m4a|webm|ogg|flac|mp4|mov)$/i.test(f.name)
|
| 30 |
+
|
| 31 |
+
const setFileIfValid = (f: File) => {
|
| 32 |
+
if (!isValidFile(f)) {
|
| 33 |
+
setError("Unsupported file type. Please upload audio or video.")
|
| 34 |
+
return
|
| 35 |
+
}
|
| 36 |
+
if (f.size > MAX_FILE_BYTES) {
|
| 37 |
+
setError(`File too large (${(f.size / 1024 / 1024).toFixed(1)} MB). Max 100 MB.`)
|
| 38 |
+
return
|
| 39 |
+
}
|
| 40 |
+
setFile(f)
|
| 41 |
+
setError(null)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 45 |
+
e.preventDefault()
|
| 46 |
+
setDragging(false)
|
| 47 |
+
const f = e.dataTransfer.files?.[0]
|
| 48 |
+
if (f) setFileIfValid(f)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const handleUpload = async () => {
|
| 52 |
+
if (!file) return
|
| 53 |
+
const id = createPendingSession(file)
|
| 54 |
+
router.push(`/studio?s=${id}`)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const isAudio = file?.type.startsWith("audio/") || /\.(wav|mp3|m4a|webm|ogg|flac)$/i.test(file?.name ?? "")
|
| 58 |
+
|
| 59 |
+
return (
|
| 60 |
+
<div className="flex flex-col min-h-screen bg-background">
|
| 61 |
+
<Navbar />
|
| 62 |
+
|
| 63 |
+
<main className="flex-1 flex flex-col items-center justify-center px-6 py-16 gap-12">
|
| 64 |
+
{/* Welcome */}
|
| 65 |
+
<div className="text-center space-y-3 max-w-lg">
|
| 66 |
+
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-border bg-muted text-xs font-medium text-muted-foreground mb-2">
|
| 67 |
+
<Waveform size={12} weight="fill" className="text-primary" />
|
| 68 |
+
Emotional Speech Recognition
|
| 69 |
+
</div>
|
| 70 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 71 |
+
Create a new session
|
| 72 |
+
</h1>
|
| 73 |
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
| 74 |
+
Upload an audio or video file and Ethos Studio will transcribe it,
|
| 75 |
+
identify speakers, and analyse emotional tone β all powered by Voxtral.
|
| 76 |
+
</p>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
{/* Upload Zone */}
|
| 80 |
+
<div className="w-full max-w-xl space-y-4">
|
| 81 |
+
<input
|
| 82 |
+
ref={inputRef}
|
| 83 |
+
type="file"
|
| 84 |
+
accept="audio/*,video/*,.wav,.mp3,.m4a,.webm,.ogg,.flac,.mp4,.mov"
|
| 85 |
+
className="hidden"
|
| 86 |
+
onChange={(e) => {
|
| 87 |
+
const f = e.target.files?.[0]
|
| 88 |
+
if (f) setFileIfValid(f)
|
| 89 |
+
}}
|
| 90 |
+
/>
|
| 91 |
+
|
| 92 |
+
{!file ? (
|
| 93 |
+
<button
|
| 94 |
+
type="button"
|
| 95 |
+
onClick={() => inputRef.current?.click()}
|
| 96 |
+
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
| 97 |
+
onDragLeave={() => setDragging(false)}
|
| 98 |
+
onDrop={handleDrop}
|
| 99 |
+
className={cn(
|
| 100 |
+
"w-full border-2 border-dashed rounded-lg p-12 flex flex-col items-center gap-4 transition-colors cursor-pointer",
|
| 101 |
+
dragging
|
| 102 |
+
? "border-primary bg-primary/5"
|
| 103 |
+
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
|
| 104 |
+
)}
|
| 105 |
+
>
|
| 106 |
+
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
|
| 107 |
+
<UploadSimple size={22} className="text-muted-foreground" />
|
| 108 |
+
</div>
|
| 109 |
+
<div className="text-center space-y-1">
|
| 110 |
+
<p className="text-sm font-semibold">Drop your file here, or click to browse</p>
|
| 111 |
+
<p className="text-xs text-muted-foreground">Audio & video files Β· MP3, WAV, MP4, MOV, M4A Β· up to 100 MB</p>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
{/* Type hints */}
|
| 115 |
+
<div className="flex items-center gap-3 mt-2">
|
| 116 |
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
| 117 |
+
<Microphone size={13} weight="fill" />
|
| 118 |
+
Audio
|
| 119 |
+
</div>
|
| 120 |
+
<span className="text-border">Β·</span>
|
| 121 |
+
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
| 122 |
+
<VideoCamera size={13} weight="fill" />
|
| 123 |
+
Video
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</button>
|
| 127 |
+
) : (
|
| 128 |
+
<div className="border border-border rounded-lg p-5 flex items-center gap-4 bg-muted/20">
|
| 129 |
+
<div className="size-10 rounded-lg bg-muted flex items-center justify-center shrink-0">
|
| 130 |
+
{isAudio
|
| 131 |
+
? <Microphone size={18} weight="fill" className="text-blue-500" />
|
| 132 |
+
: <VideoCamera size={18} weight="fill" className="text-pink-500" />
|
| 133 |
+
}
|
| 134 |
+
</div>
|
| 135 |
+
<div className="flex-1 min-w-0">
|
| 136 |
+
<p className="text-sm font-medium truncate">{file.name}</p>
|
| 137 |
+
<p className="text-xs text-muted-foreground">
|
| 138 |
+
{(file.size / 1024 / 1024).toFixed(1)} MB Β· {isAudio ? "Audio" : "Video"}
|
| 139 |
+
</p>
|
| 140 |
+
</div>
|
| 141 |
+
<div className="flex items-center gap-2">
|
| 142 |
+
<CheckCircle size={16} weight="fill" className="text-green-500 shrink-0" />
|
| 143 |
+
<Button
|
| 144 |
+
variant="ghost"
|
| 145 |
+
size="icon"
|
| 146 |
+
className="h-7 w-7 text-muted-foreground"
|
| 147 |
+
onClick={() => { setFile(null); setError(null) }}
|
| 148 |
+
>
|
| 149 |
+
<X size={14} />
|
| 150 |
+
</Button>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
)}
|
| 154 |
+
|
| 155 |
+
{error && (
|
| 156 |
+
<p className="text-sm text-destructive font-medium text-center">{error}</p>
|
| 157 |
+
)}
|
| 158 |
+
|
| 159 |
+
<Button
|
| 160 |
+
className="w-full gap-2 font-semibold h-10"
|
| 161 |
+
disabled={!file || loading}
|
| 162 |
+
onClick={handleUpload}
|
| 163 |
+
>
|
| 164 |
+
{loading ? (
|
| 165 |
+
<>
|
| 166 |
+
<div className="size-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
| 167 |
+
{progress || "Processingβ¦"}
|
| 168 |
+
</>
|
| 169 |
+
) : (
|
| 170 |
+
<>
|
| 171 |
+
<Waveform size={16} weight="bold" />
|
| 172 |
+
Transcribe & Analyse
|
| 173 |
+
</>
|
| 174 |
+
)}
|
| 175 |
+
</Button>
|
| 176 |
+
</div>
|
| 177 |
+
</main>
|
| 178 |
+
</div>
|
| 179 |
+
)
|
| 180 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/app/studio/page.tsx
CHANGED
|
@@ -1,528 +1,574 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import React, { useState, useRef, useEffect, useMemo, Suspense } from "react"
|
| 4 |
-
import Link from "next/link"
|
| 5 |
-
import { useSearchParams } from "next/navigation"
|
| 6 |
-
import {
|
| 7 |
-
ArrowLeft, ArrowCounterClockwise, ArrowClockwise,
|
| 8 |
-
Export, Play, Pause, Plus, DotsThreeVertical,
|
| 9 |
-
MinusCircle, PlusCircle, Waveform,
|
| 10 |
-
} from "@phosphor-icons/react"
|
| 11 |
-
import { Button } from "@/components/ui/button"
|
| 12 |
-
import { Badge } from "@/components/ui/badge"
|
| 13 |
-
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 14 |
-
import { Separator } from "@/components/ui/separator"
|
| 15 |
-
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 16 |
-
import { Slider } from "@/components/ui/slider"
|
| 17 |
-
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
| 18 |
-
import {
|
| 19 |
-
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
| 20 |
-
} from "@/components/ui/dropdown-menu"
|
| 21 |
-
import {
|
| 22 |
-
Collapsible, CollapsibleContent, CollapsibleTrigger,
|
| 23 |
-
} from "@/components/ui/collapsible"
|
| 24 |
-
import { Progress } from "@/components/ui/progress"
|
| 25 |
-
import { getSession, type Segment } from "@/lib/session-store"
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
{ avatar: "bg-
|
| 36 |
-
{ avatar: "bg-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
{
|
| 42 |
-
{
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
{ id:
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
active
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
{
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
<
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
className="
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
<
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
<ScrollArea
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
</
|
| 231 |
-
</
|
| 232 |
-
</
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
<>
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
<
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
const
|
| 390 |
-
const
|
| 391 |
-
|
| 392 |
-
const
|
| 393 |
-
|
| 394 |
-
const
|
| 395 |
-
const
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
const
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
<
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
<
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React, { useState, useRef, useEffect, useMemo, Suspense } from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { useSearchParams, useRouter } from "next/navigation"
|
| 6 |
+
import {
|
| 7 |
+
ArrowLeft, ArrowCounterClockwise, ArrowClockwise,
|
| 8 |
+
Export, Play, Pause, Plus, DotsThreeVertical,
|
| 9 |
+
MinusCircle, PlusCircle, Waveform, VideoCamera,
|
| 10 |
+
} from "@phosphor-icons/react"
|
| 11 |
+
import { Button } from "@/components/ui/button"
|
| 12 |
+
import { Badge } from "@/components/ui/badge"
|
| 13 |
+
import { ScrollArea } from "@/components/ui/scroll-area"
|
| 14 |
+
import { Separator } from "@/components/ui/separator"
|
| 15 |
+
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
| 16 |
+
import { Slider } from "@/components/ui/slider"
|
| 17 |
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
| 18 |
+
import {
|
| 19 |
+
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
| 20 |
+
} from "@/components/ui/dropdown-menu"
|
| 21 |
+
import {
|
| 22 |
+
Collapsible, CollapsibleContent, CollapsibleTrigger,
|
| 23 |
+
} from "@/components/ui/collapsible"
|
| 24 |
+
import { Progress } from "@/components/ui/progress"
|
| 25 |
+
import { getSession, updateSession, type Segment, type DiarizeResult } from "@/lib/session-store"
|
| 26 |
+
import { Navbar } from "@/components/navbar"
|
| 27 |
+
import { cn } from "@/lib/utils"
|
| 28 |
+
import { MagnifyingGlass } from "@phosphor-icons/react"
|
| 29 |
+
import NextImage from "next/image"
|
| 30 |
+
|
| 31 |
+
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"
|
| 32 |
+
|
| 33 |
+
// --- Constants ---
|
| 34 |
+
const SPEAKER_COLORS = [
|
| 35 |
+
{ avatar: "bg-blue-400", track: "bg-blue-200" },
|
| 36 |
+
{ avatar: "bg-pink-400", track: "bg-pink-200" },
|
| 37 |
+
{ avatar: "bg-emerald-400", track: "bg-emerald-200" },
|
| 38 |
+
{ avatar: "bg-amber-400", track: "bg-amber-200" },
|
| 39 |
+
{ avatar: "bg-violet-400", track: "bg-violet-200" },
|
| 40 |
+
{ avatar: "bg-cyan-400", track: "bg-cyan-200" },
|
| 41 |
+
{ avatar: "bg-rose-400", track: "bg-rose-200" },
|
| 42 |
+
{ avatar: "bg-lime-400", track: "bg-lime-200" },
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
// --- Mock Data (demo / fallback) ---
|
| 46 |
+
const MOCK_SEGMENTS: Segment[] = [
|
| 47 |
+
{ id: 1, speaker: "Theodore", start: 0.10, end: 7.28, text: "[Instrumental music plays]", emotion: "Neutral", valence: 0.0, arousal: 0.1 },
|
| 48 |
+
{ id: 2, speaker: "Theodore", start: 8.10, end: 9.04, text: "Hello, I'm here.", emotion: "Calm", valence: 0.3, arousal: -0.1 },
|
| 49 |
+
{ id: 3, speaker: "Samantha", start: 10.62, end: 11.00, text: "Oh.", emotion: "Surprise", valence: 0.4, arousal: 0.6 },
|
| 50 |
+
{ id: 4, speaker: "Samantha", start: 13.82, end: 14.18, text: "Hi.", emotion: "Neutral", valence: 0.1, arousal: 0.0 },
|
| 51 |
+
{ id: 5, speaker: "Theodore", start: 14.82, end: 16.40, text: "Hi.", emotion: "Happy", valence: 0.7, arousal: 0.4 },
|
| 52 |
+
]
|
| 53 |
+
const MOCK_FILENAME = "WeChat_20250804025710.mp4"
|
| 54 |
+
const MOCK_DURATION = 28.50
|
| 55 |
+
|
| 56 |
+
// --- Helpers ---
|
| 57 |
+
function fmtTime(sec: number): string {
|
| 58 |
+
const m = Math.floor(sec / 60)
|
| 59 |
+
const s = (sec % 60).toFixed(2)
|
| 60 |
+
return m > 0 ? `${m}:${s.padStart(5, "0")}` : s
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
type SpeakerInfo = { label: string; avatarColor: string; trackColor: string }
|
| 64 |
+
|
| 65 |
+
function buildSpeakerMap(segments: Segment[]): Record<string, SpeakerInfo> {
|
| 66 |
+
const speakers = [...new Set(segments.map(s => s.speaker))].sort()
|
| 67 |
+
return Object.fromEntries(
|
| 68 |
+
speakers.map((id, i) => {
|
| 69 |
+
const palette = SPEAKER_COLORS[i % SPEAKER_COLORS.length]
|
| 70 |
+
// Use the speaker ID as the label if it's a name, otherwise "Speaker N"
|
| 71 |
+
const label = id.startsWith("SPEAKER_") ? `Speaker ${i + 1}` : id
|
| 72 |
+
return [id, { label, avatarColor: palette.avatar, trackColor: palette.track }]
|
| 73 |
+
})
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// --- SegmentRow ---
|
| 78 |
+
function SegmentRow({
|
| 79 |
+
seg,
|
| 80 |
+
active,
|
| 81 |
+
speaker,
|
| 82 |
+
onClick,
|
| 83 |
+
}: {
|
| 84 |
+
seg: Segment
|
| 85 |
+
active: boolean
|
| 86 |
+
speaker: SpeakerInfo
|
| 87 |
+
onClick: () => void
|
| 88 |
+
}) {
|
| 89 |
+
return (
|
| 90 |
+
<div
|
| 91 |
+
onClick={onClick}
|
| 92 |
+
className={cn(
|
| 93 |
+
"flex items-start gap-12 group transition-all duration-300 cursor-pointer py-4 rounded-lg px-4 border border-transparent",
|
| 94 |
+
active ? "bg-accent/[0.04] border-accent/10" : "hover:bg-muted/30"
|
| 95 |
+
)}
|
| 96 |
+
>
|
| 97 |
+
{/* Speaker */}
|
| 98 |
+
<div className="w-32 flex items-center gap-3 shrink-0 pt-1">
|
| 99 |
+
<Avatar className="size-6 ring-2 ring-background border border-border/20">
|
| 100 |
+
<AvatarFallback className={cn("text-[10px] text-white", speaker.avatarColor)}>
|
| 101 |
+
{speaker.label[0]}
|
| 102 |
+
</AvatarFallback>
|
| 103 |
+
</Avatar>
|
| 104 |
+
<span className="text-[13px] font-semibold text-foreground/70">{speaker.label}</span>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Content */}
|
| 108 |
+
<div className="flex-1 space-y-1.5 min-w-0 border-l-[1.5px] border-border/30 pl-8 relative">
|
| 109 |
+
<span className="text-[10px] font-sans font-medium text-muted-foreground/40 block tracking-tight">{fmtTime(seg.start)}</span>
|
| 110 |
+
<p className="text-[15px] text-foreground leading-[1.6] font-medium tracking-tight whitespace-pre-wrap">{seg.text}</p>
|
| 111 |
+
<div className="flex items-center gap-2 flex-wrap pt-0.5">
|
| 112 |
+
{seg.emotion && (
|
| 113 |
+
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-medium rounded-full">
|
| 114 |
+
{seg.emotion}
|
| 115 |
+
</Badge>
|
| 116 |
+
)}
|
| 117 |
+
{seg.face_emotion && (
|
| 118 |
+
<Badge variant="outline" className="text-[10px] h-5 px-2 font-medium rounded-full gap-1">
|
| 119 |
+
<VideoCamera size={9} weight="fill" className="text-pink-500" />
|
| 120 |
+
{seg.face_emotion}
|
| 121 |
+
</Badge>
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
+
<span className="text-[10px] font-sans font-medium text-muted-foreground/40 block tracking-tight">{fmtTime(seg.end)}</span>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function MergeButton() {
|
| 131 |
+
return (
|
| 132 |
+
<div className="flex items-center gap-12 px-4 py-0.5 group/merge h-6">
|
| 133 |
+
<div className="w-32 flex justify-end pr-5 shrink-0">
|
| 134 |
+
<Tooltip>
|
| 135 |
+
<TooltipTrigger asChild>
|
| 136 |
+
<button className="text-muted-foreground/20 hover:text-foreground transition-all duration-200 transform hover:scale-125">
|
| 137 |
+
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor">
|
| 138 |
+
<path d="M205.66,117.66l-32,32a8,8,0,0,1-11.32-11.32L188.69,112l-26.35-26.34a8,8,0,0,1,11.32-11.32l32,32A8,8,0,0,1,205.66,117.66ZM81.66,138.34,55.31,112,81.66,85.66a8,8,0,0,0-11.32-11.32l-32,32a8,8,0,0,0,0,11.32l32,32a8,8,0,0,0,11.32-11.32Z" />
|
| 139 |
+
</svg>
|
| 140 |
+
</button>
|
| 141 |
+
</TooltipTrigger>
|
| 142 |
+
<TooltipContent side="right" className="bg-black text-white border-black">Merge segments</TooltipContent>
|
| 143 |
+
</Tooltip>
|
| 144 |
+
</div>
|
| 145 |
+
<div className="flex-1 h-px bg-border/20" />
|
| 146 |
+
</div>
|
| 147 |
+
)
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// --- RightPanel ---
|
| 151 |
+
function RightPanel({
|
| 152 |
+
filename,
|
| 153 |
+
onToggle,
|
| 154 |
+
}: {
|
| 155 |
+
activeSegment: Segment | null
|
| 156 |
+
audioUrl: string
|
| 157 |
+
filename: string
|
| 158 |
+
isPlaying: boolean
|
| 159 |
+
currentTime: number
|
| 160 |
+
duration: number
|
| 161 |
+
onToggle: () => void
|
| 162 |
+
}) {
|
| 163 |
+
return (
|
| 164 |
+
<div className="flex flex-col h-full border-l border-border bg-background">
|
| 165 |
+
{/* Video Preview */}
|
| 166 |
+
<div className="aspect-video w-full bg-slate-950 flex items-center justify-center flex-shrink-0 relative group">
|
| 167 |
+
<NextImage src="/logo.svg" alt="Preview" width={48} height={48} className="opacity-10" />
|
| 168 |
+
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
| 169 |
+
<Button variant="ghost" size="icon" className="text-white hover:bg-white/20" onClick={onToggle}>
|
| 170 |
+
<Play size={24} weight="fill" />
|
| 171 |
+
</Button>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<ScrollArea className="flex-1">
|
| 176 |
+
<div className="p-6 space-y-8">
|
| 177 |
+
{/* Global Properties */}
|
| 178 |
+
<div className="space-y-4">
|
| 179 |
+
<button className="flex items-center gap-2 text-[11px] font-bold text-muted-foreground uppercase tracking-widest hover:text-foreground transition-colors w-full text-left">
|
| 180 |
+
<span className="text-[8px]">βΌ</span> Global Properties
|
| 181 |
+
</button>
|
| 182 |
+
<div className="space-y-4 pt-2">
|
| 183 |
+
<div className="text-sm font-medium">{filename}</div>
|
| 184 |
+
<div className="flex items-center justify-between text-xs">
|
| 185 |
+
<span className="text-muted-foreground">Language</span>
|
| 186 |
+
<span className="font-medium text-foreground">English</span>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="flex items-center justify-between text-xs">
|
| 189 |
+
<span className="text-muted-foreground">Subtitles</span>
|
| 190 |
+
<Button variant="ghost" size="icon" className="h-4 w-4 text-muted-foreground">
|
| 191 |
+
<DotsThreeVertical size={14} />
|
| 192 |
+
</Button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
</div>
|
| 199 |
+
</ScrollArea>
|
| 200 |
+
</div>
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
// --- TimelineBar ---
|
| 205 |
+
function TimelineBar({
|
| 206 |
+
isPlaying,
|
| 207 |
+
onToggle,
|
| 208 |
+
segments,
|
| 209 |
+
duration,
|
| 210 |
+
currentTime,
|
| 211 |
+
speakerMap,
|
| 212 |
+
}: {
|
| 213 |
+
isPlaying: boolean
|
| 214 |
+
onToggle: () => void
|
| 215 |
+
segments: Segment[]
|
| 216 |
+
duration: number
|
| 217 |
+
currentTime: number
|
| 218 |
+
speakerMap: Record<string, SpeakerInfo>
|
| 219 |
+
}) {
|
| 220 |
+
const speakers = Object.entries(speakerMap)
|
| 221 |
+
|
| 222 |
+
return (
|
| 223 |
+
<div className="border-t border-border bg-background flex-shrink-0">
|
| 224 |
+
<div className="flex overflow-hidden">
|
| 225 |
+
{/* Speakers Sidebar */}
|
| 226 |
+
<div className="w-48 flex-shrink-0 border-r border-border">
|
| 227 |
+
<div className="px-4 h-12 flex items-center justify-between border-b border-border/40">
|
| 228 |
+
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">Speakers</span>
|
| 229 |
+
<Button variant="ghost" size="icon" className="size-5">
|
| 230 |
+
<Plus size={14} />
|
| 231 |
+
</Button>
|
| 232 |
+
</div>
|
| 233 |
+
<div>
|
| 234 |
+
{speakers.map(([id, info]) => (
|
| 235 |
+
<div key={id} className="h-11 px-3 flex items-center gap-2 group hover:bg-muted/30 transition-colors">
|
| 236 |
+
<DotsThreeVertical size={14} className="text-muted-foreground opacity-30 cursor-grab" />
|
| 237 |
+
<Avatar className="size-5">
|
| 238 |
+
<AvatarFallback className={cn("text-[8px] text-white", info.avatarColor)}>
|
| 239 |
+
{info.label[0]}
|
| 240 |
+
</AvatarFallback>
|
| 241 |
+
</Avatar>
|
| 242 |
+
<span className="text-xs font-medium truncate flex-1">{info.label}</span>
|
| 243 |
+
<Button variant="ghost" size="icon" className="size-5 opacity-0 group-hover:opacity-100">
|
| 244 |
+
<DotsThreeVertical size={14} />
|
| 245 |
+
</Button>
|
| 246 |
+
</div>
|
| 247 |
+
))}
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
{/* Tracks Area */}
|
| 252 |
+
<div className="flex-1 relative overflow-hidden">
|
| 253 |
+
{/* Time Marks */}
|
| 254 |
+
<div className="h-12 border-b border-border/40 flex items-center relative px-4">
|
| 255 |
+
<Badge variant="secondary" className="bg-black text-white text-[10px] h-5 px-1.5 rounded-[4px] absolute left-4 z-10">0.00</Badge>
|
| 256 |
+
{[5, 10, 15, 20].map(s => (
|
| 257 |
+
<div key={s} className="absolute text-[10px] text-muted-foreground/40 font-sans border-l border-border/40 h-2 top-1/2 -translate-y-1/2" style={{ left: `${(s / duration) * 100}%` }}>
|
| 258 |
+
<span className="ml-1 relative -top-3">0:{s.toString().padStart(2, '0')}</span>
|
| 259 |
+
</div>
|
| 260 |
+
))}
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{/* Track Grid */}
|
| 264 |
+
<div className="relative">
|
| 265 |
+
{speakers.map(([id, info]) => (
|
| 266 |
+
<div key={id} className="h-11 border-b border-border/20 relative">
|
| 267 |
+
{duration > 0 && segments.filter(s => s.speaker === id).map(seg => (
|
| 268 |
+
<div
|
| 269 |
+
key={seg.id}
|
| 270 |
+
className={cn("absolute top-2 bottom-2 rounded-[6px] transition-opacity hover:opacity-80 cursor-alias", info.trackColor)}
|
| 271 |
+
style={{
|
| 272 |
+
left: `${(seg.start / duration) * 100}%`,
|
| 273 |
+
width: `${Math.max(((seg.end - seg.start) / duration) * 100, 0.5)}%`,
|
| 274 |
+
}}
|
| 275 |
+
/>
|
| 276 |
+
))}
|
| 277 |
+
</div>
|
| 278 |
+
))}
|
| 279 |
+
|
| 280 |
+
{/* Playhead */}
|
| 281 |
+
{duration > 0 && (
|
| 282 |
+
<div
|
| 283 |
+
className="absolute top-0 bottom-0 w-px bg-black z-20 pointer-events-none transition-all duration-75"
|
| 284 |
+
style={{
|
| 285 |
+
left: (currentTime / duration) * 100 + "%"
|
| 286 |
+
}}
|
| 287 |
+
/>
|
| 288 |
+
)}
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
{/* Bottom Controls */}
|
| 294 |
+
<div className="flex items-center justify-between px-6 h-12 border-t border-border/40">
|
| 295 |
+
<div className="flex items-center gap-4">
|
| 296 |
+
<div className="flex items-center gap-2">
|
| 297 |
+
<MagnifyingGlass size={14} className="text-muted-foreground" />
|
| 298 |
+
<Slider defaultValue={[40]} max={100} className="w-32" />
|
| 299 |
+
<MagnifyingGlass size={14} className="text-muted-foreground" />
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
|
| 303 |
+
<div className="flex items-center gap-6">
|
| 304 |
+
<span className="text-[13px] font-bold text-foreground">1.0x</span>
|
| 305 |
+
<button
|
| 306 |
+
onClick={onToggle}
|
| 307 |
+
className="size-8 rounded-full bg-black text-white flex items-center justify-center hover:scale-105 transition-transform"
|
| 308 |
+
>
|
| 309 |
+
{isPlaying ? <Pause size={16} weight="fill" /> : <Play size={16} weight="fill" className="ml-0.5" />}
|
| 310 |
+
</button>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<Button variant="ghost" size="sm" className="text-xs font-bold hover:bg-transparent">
|
| 314 |
+
Add segment
|
| 315 |
+
</Button>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
)
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// --- Studio Content ---
|
| 322 |
+
function StudioContent() {
|
| 323 |
+
const searchParams = useSearchParams()
|
| 324 |
+
const sessionId = searchParams.get("s")
|
| 325 |
+
const router = useRouter()
|
| 326 |
+
|
| 327 |
+
const audioRef = useRef<HTMLAudioElement>(null)
|
| 328 |
+
|
| 329 |
+
const [session, setSession] = useState(() => sessionId ? getSession(sessionId) : null)
|
| 330 |
+
const [activeId, setActiveId] = useState<number>(1)
|
| 331 |
+
const [isPlaying, setIsPlaying] = useState(false)
|
| 332 |
+
const [currentTime, setCurrentTime] = useState(0)
|
| 333 |
+
const [isProcessing, setIsProcessing] = useState(false)
|
| 334 |
+
const [processError, setProcessError] = useState<string | null>(null)
|
| 335 |
+
|
| 336 |
+
// Sync session state with sessionId param
|
| 337 |
+
useEffect(() => {
|
| 338 |
+
if (sessionId) {
|
| 339 |
+
const s = getSession(sessionId)
|
| 340 |
+
setSession(s)
|
| 341 |
+
if (s?.data.segments && s.data.segments.length > 0) {
|
| 342 |
+
setActiveId(s.data.segments[0].id)
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
}, [sessionId])
|
| 346 |
+
|
| 347 |
+
// Automatic processing for pending sessions
|
| 348 |
+
useEffect(() => {
|
| 349 |
+
if (!session || isProcessing || processError) return
|
| 350 |
+
|
| 351 |
+
// If we have a file but no segments, it's a pending session
|
| 352 |
+
if (session.file && session.data.segments.length === 0) {
|
| 353 |
+
const process = async () => {
|
| 354 |
+
setIsProcessing(true)
|
| 355 |
+
setProcessError(null)
|
| 356 |
+
try {
|
| 357 |
+
const formData = new FormData()
|
| 358 |
+
formData.append("audio", session.file!, session.filename)
|
| 359 |
+
|
| 360 |
+
const res = await fetch(`${API_BASE}/api/transcribe-diarize`, {
|
| 361 |
+
method: "POST",
|
| 362 |
+
body: formData,
|
| 363 |
+
})
|
| 364 |
+
|
| 365 |
+
if (!res.ok) {
|
| 366 |
+
const errData = await res.json().catch(() => ({}))
|
| 367 |
+
throw new Error(errData.error ?? "Processing failed")
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
const data = await res.json() as DiarizeResult
|
| 371 |
+
updateSession(session.id, data)
|
| 372 |
+
|
| 373 |
+
// Re-fetch to update local state and trigger re-render
|
| 374 |
+
const updated = getSession(session.id)
|
| 375 |
+
setSession(updated)
|
| 376 |
+
if (updated?.data.segments && updated.data.segments.length > 0) {
|
| 377 |
+
setActiveId(updated.data.segments[0].id)
|
| 378 |
+
}
|
| 379 |
+
} catch (e) {
|
| 380 |
+
setProcessError(e instanceof Error ? e.message : "Request failed")
|
| 381 |
+
} finally {
|
| 382 |
+
setIsProcessing(false)
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
process()
|
| 386 |
+
}
|
| 387 |
+
}, [session, isProcessing, processError])
|
| 388 |
+
|
| 389 |
+
const segments = session?.data.segments ?? (session ? [] : MOCK_SEGMENTS)
|
| 390 |
+
const audioUrl = session?.audioUrl ?? ""
|
| 391 |
+
const filename = session?.filename ?? MOCK_FILENAME
|
| 392 |
+
const duration = session?.data.duration ?? MOCK_DURATION
|
| 393 |
+
|
| 394 |
+
const speakerMap = useMemo(() => buildSpeakerMap(segments), [segments])
|
| 395 |
+
const activeSegment = segments.find(s => s.id === activeId) ?? segments[0] ?? null
|
| 396 |
+
|
| 397 |
+
useEffect(() => {
|
| 398 |
+
const audio = audioRef.current
|
| 399 |
+
if (!audio) return
|
| 400 |
+
const onTime = () => setCurrentTime(audio.currentTime)
|
| 401 |
+
const onPlay = () => setIsPlaying(true)
|
| 402 |
+
const onPause = () => setIsPlaying(false)
|
| 403 |
+
const onLoadedMetadata = () => {
|
| 404 |
+
// If session duration is 0 (pending), update it from audio metadata
|
| 405 |
+
if (duration === 0) {
|
| 406 |
+
// We could update the store here, but let's just use it locally for now
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
audio.addEventListener("timeupdate", onTime)
|
| 410 |
+
audio.addEventListener("play", onPlay)
|
| 411 |
+
audio.addEventListener("pause", onPause)
|
| 412 |
+
audio.addEventListener("loadedmetadata", onLoadedMetadata)
|
| 413 |
+
return () => {
|
| 414 |
+
audio.removeEventListener("timeupdate", onTime)
|
| 415 |
+
audio.removeEventListener("play", onPlay)
|
| 416 |
+
audio.removeEventListener("pause", onPause)
|
| 417 |
+
audio.removeEventListener("loadedmetadata", onLoadedMetadata)
|
| 418 |
+
}
|
| 419 |
+
}, [audioUrl, duration])
|
| 420 |
+
|
| 421 |
+
const handleSegmentClick = (seg: Segment) => {
|
| 422 |
+
setActiveId(seg.id)
|
| 423 |
+
if (audioRef.current && audioUrl) {
|
| 424 |
+
audioRef.current.currentTime = seg.start
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
const togglePlay = () => {
|
| 429 |
+
const audio = audioRef.current
|
| 430 |
+
if (!audio || !audioUrl) return
|
| 431 |
+
if (isPlaying) audio.pause()
|
| 432 |
+
else audio.play()
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
return (
|
| 436 |
+
<div className="flex flex-col h-screen bg-background text-foreground overflow-hidden">
|
| 437 |
+
{/* Hidden audio element */}
|
| 438 |
+
<audio ref={audioRef} src={audioUrl || undefined} preload="metadata" className="hidden" />
|
| 439 |
+
|
| 440 |
+
{/* Top Bar */}
|
| 441 |
+
<Navbar
|
| 442 |
+
breadcrumbs={[
|
| 443 |
+
{ label: filename }
|
| 444 |
+
]}
|
| 445 |
+
actions={
|
| 446 |
+
<div className="flex items-center gap-2">
|
| 447 |
+
{isProcessing && (
|
| 448 |
+
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500 hover:bg-blue-500/10 border-blue-500/20 gap-2 font-medium px-3 h-8">
|
| 449 |
+
<div className="size-2 rounded-full bg-blue-500 animate-pulse" />
|
| 450 |
+
Analysing Speech...
|
| 451 |
+
</Badge>
|
| 452 |
+
)}
|
| 453 |
+
|
| 454 |
+
<div className="flex items-center gap-0.5 border border-border rounded-[12px] p-0.5 bg-muted/20 ml-2">
|
| 455 |
+
<Tooltip>
|
| 456 |
+
<TooltipTrigger asChild>
|
| 457 |
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-background rounded-[8px]">
|
| 458 |
+
<ArrowCounterClockwise size={16} />
|
| 459 |
+
</Button>
|
| 460 |
+
</TooltipTrigger>
|
| 461 |
+
<TooltipContent>Undo</TooltipContent>
|
| 462 |
+
</Tooltip>
|
| 463 |
+
<Tooltip>
|
| 464 |
+
<TooltipTrigger asChild>
|
| 465 |
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-background rounded-[8px]">
|
| 466 |
+
<ArrowClockwise size={16} />
|
| 467 |
+
</Button>
|
| 468 |
+
</TooltipTrigger>
|
| 469 |
+
<TooltipContent>Redo</TooltipContent>
|
| 470 |
+
</Tooltip>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
<Separator orientation="vertical" className="h-4 mx-1" />
|
| 474 |
+
|
| 475 |
+
<Button className="gap-2 font-semibold h-8 text-xs px-4 rounded-[12px] bg-foreground text-background hover:bg-foreground/90 shadow-none border border-transparent">
|
| 476 |
+
<Export size={16} weight="bold" />
|
| 477 |
+
Export
|
| 478 |
+
</Button>
|
| 479 |
+
</div>
|
| 480 |
+
}
|
| 481 |
+
/>
|
| 482 |
+
|
| 483 |
+
{/* Body */}
|
| 484 |
+
<div className="flex flex-1 overflow-hidden">
|
| 485 |
+
{/* Left: Transcript */}
|
| 486 |
+
<div className="flex-1 overflow-hidden flex flex-col min-w-0 relative">
|
| 487 |
+
{isProcessing && segments.length === 0 && (
|
| 488 |
+
<div className="absolute inset-0 z-20 bg-background/80 backdrop-blur-sm flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-500">
|
| 489 |
+
<div className="size-16 mb-6 relative">
|
| 490 |
+
<div className="absolute inset-0 border-4 border-muted rounded-full" />
|
| 491 |
+
<div className="absolute inset-0 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
| 492 |
+
<Waveform size={24} className="absolute inset-0 m-auto text-primary animate-pulse" />
|
| 493 |
+
</div>
|
| 494 |
+
<h2 className="text-xl font-bold mb-2">Analysing Audio</h2>
|
| 495 |
+
<p className="text-muted-foreground text-sm max-w-xs mx-auto">
|
| 496 |
+
Voxtral is currently transcribing and identifying speakers. This should only take a moment...
|
| 497 |
+
</p>
|
| 498 |
+
</div>
|
| 499 |
+
)}
|
| 500 |
+
|
| 501 |
+
{processError && (
|
| 502 |
+
<div className="absolute inset-0 z-20 bg-background flex flex-col items-center justify-center p-8 text-center">
|
| 503 |
+
<div className="size-12 rounded-full bg-destructive/10 text-destructive flex items-center justify-center mb-4">
|
| 504 |
+
<Export size={24} className="rotate-45" /> {/* Use as X icon */}
|
| 505 |
+
</div>
|
| 506 |
+
<h2 className="text-xl font-bold mb-2">Processing Failed</h2>
|
| 507 |
+
<p className="text-muted-foreground text-sm max-w-sm mx-auto mb-6">
|
| 508 |
+
{processError}
|
| 509 |
+
</p>
|
| 510 |
+
<Button onClick={() => window.location.reload()} variant="outline">Try Again</Button>
|
| 511 |
+
</div>
|
| 512 |
+
)}
|
| 513 |
+
|
| 514 |
+
<ScrollArea className="flex-1">
|
| 515 |
+
<div className="max-w-none px-[200px] py-10">
|
| 516 |
+
{segments.length === 0 && !isProcessing && !processError && (
|
| 517 |
+
<div className="py-20 text-center text-muted-foreground">
|
| 518 |
+
No segments found.
|
| 519 |
+
</div>
|
| 520 |
+
)}
|
| 521 |
+
{segments.map((seg, i) => (
|
| 522 |
+
<React.Fragment key={seg.id}>
|
| 523 |
+
{i > 0 && segments[i - 1].speaker === seg.speaker && <MergeButton />}
|
| 524 |
+
<SegmentRow
|
| 525 |
+
seg={seg}
|
| 526 |
+
active={seg.id === activeId}
|
| 527 |
+
speaker={speakerMap[seg.speaker]}
|
| 528 |
+
onClick={() => handleSegmentClick(seg)}
|
| 529 |
+
/>
|
| 530 |
+
</React.Fragment>
|
| 531 |
+
))}
|
| 532 |
+
</div>
|
| 533 |
+
</ScrollArea>
|
| 534 |
+
</div>
|
| 535 |
+
|
| 536 |
+
{/* Right: Properties panel */}
|
| 537 |
+
<div className="w-[320px] flex-shrink-0 overflow-hidden flex flex-col">
|
| 538 |
+
<RightPanel
|
| 539 |
+
activeSegment={activeSegment}
|
| 540 |
+
audioUrl={audioUrl}
|
| 541 |
+
filename={filename}
|
| 542 |
+
isPlaying={isPlaying}
|
| 543 |
+
currentTime={currentTime}
|
| 544 |
+
duration={duration}
|
| 545 |
+
onToggle={togglePlay}
|
| 546 |
+
/>
|
| 547 |
+
</div>
|
| 548 |
+
</div>
|
| 549 |
+
|
| 550 |
+
{/* Bottom: Timeline */}
|
| 551 |
+
<TimelineBar
|
| 552 |
+
isPlaying={isPlaying}
|
| 553 |
+
onToggle={togglePlay}
|
| 554 |
+
segments={segments}
|
| 555 |
+
duration={duration}
|
| 556 |
+
currentTime={currentTime}
|
| 557 |
+
speakerMap={speakerMap}
|
| 558 |
+
/>
|
| 559 |
+
</div>
|
| 560 |
+
)
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
// --- Page (wraps in Suspense for useSearchParams) ---
|
| 564 |
+
export default function StudioPage() {
|
| 565 |
+
return (
|
| 566 |
+
<Suspense fallback={
|
| 567 |
+
<div className="h-screen flex items-center justify-center text-muted-foreground text-sm">
|
| 568 |
+
Loadingβ¦
|
| 569 |
+
</div>
|
| 570 |
+
}>
|
| 571 |
+
<StudioContent />
|
| 572 |
+
</Suspense>
|
| 573 |
+
)
|
| 574 |
+
}
|
web/src/components/navbar.tsx
CHANGED
|
@@ -1,33 +1,96 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import React from "react"
|
| 4 |
-
import
|
| 5 |
-
import {
|
| 6 |
-
import {
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { usePathname } from "next/navigation"
|
| 6 |
+
import {
|
| 7 |
+
ArrowLeft,
|
| 8 |
+
Plus,
|
| 9 |
+
Bell,
|
| 10 |
+
Microphone,
|
| 11 |
+
VideoCamera,
|
| 12 |
+
} from "@phosphor-icons/react"
|
| 13 |
+
import { Button } from "@/components/ui/button"
|
| 14 |
+
import { Separator } from "@/components/ui/separator"
|
| 15 |
+
import { SidebarTrigger } from "@/components/ui/sidebar"
|
| 16 |
+
import {
|
| 17 |
+
DropdownMenu,
|
| 18 |
+
DropdownMenuContent,
|
| 19 |
+
DropdownMenuItem,
|
| 20 |
+
DropdownMenuTrigger,
|
| 21 |
+
} from "@/components/ui/dropdown-menu"
|
| 22 |
+
import { cn } from "@/lib/utils"
|
| 23 |
+
|
| 24 |
+
interface NavbarProps {
|
| 25 |
+
children?: React.ReactNode
|
| 26 |
+
breadcrumbs?: { label: string; href?: string }[]
|
| 27 |
+
actions?: React.ReactNode
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export function Navbar({ children, breadcrumbs, actions }: NavbarProps) {
|
| 31 |
+
const pathname = usePathname()
|
| 32 |
+
|
| 33 |
+
// Default breadcrumbs if none provided
|
| 34 |
+
const defaultBreadcrumbs = React.useMemo(() => {
|
| 35 |
+
if (breadcrumbs) return breadcrumbs
|
| 36 |
+
|
| 37 |
+
const parts = pathname.split("/").filter(Boolean)
|
| 38 |
+
if (parts.length === 0) return [{ label: "Create", href: "/" }]
|
| 39 |
+
|
| 40 |
+
return parts.map((part, i) => {
|
| 41 |
+
// Custom labels for known routes
|
| 42 |
+
let label = part.charAt(0).toUpperCase() + part.slice(1)
|
| 43 |
+
if (part === "studio") label = "Studio Session"
|
| 44 |
+
if (part === "files") label = "Files"
|
| 45 |
+
|
| 46 |
+
return {
|
| 47 |
+
label,
|
| 48 |
+
href: "/" + parts.slice(0, i + 1).join("/")
|
| 49 |
+
}
|
| 50 |
+
})
|
| 51 |
+
}, [pathname, breadcrumbs])
|
| 52 |
+
|
| 53 |
+
return (
|
| 54 |
+
<header className="h-12 border-b border-border bg-background flex items-center px-4 gap-4 flex-shrink-0 sticky top-0 z-50">
|
| 55 |
+
<div className="flex items-center gap-2">
|
| 56 |
+
<SidebarTrigger className="-ml-2" />
|
| 57 |
+
<Separator orientation="vertical" className="h-4 mx-1" />
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
<div className="flex items-center gap-2 text-xs font-semibold">
|
| 62 |
+
{defaultBreadcrumbs.map((crumb, i) => (
|
| 63 |
+
<React.Fragment key={crumb.label}>
|
| 64 |
+
{i > 0 && <span className="text-muted-foreground/30 font-normal">/</span>}
|
| 65 |
+
{crumb.href ? (
|
| 66 |
+
<Link
|
| 67 |
+
href={crumb.href}
|
| 68 |
+
className={cn(
|
| 69 |
+
"transition-colors",
|
| 70 |
+
i === defaultBreadcrumbs.length - 1 ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
| 71 |
+
)}
|
| 72 |
+
>
|
| 73 |
+
{crumb.label}
|
| 74 |
+
</Link>
|
| 75 |
+
) : (
|
| 76 |
+
<span className="text-foreground">{crumb.label}</span>
|
| 77 |
+
)}
|
| 78 |
+
</React.Fragment>
|
| 79 |
+
))}
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div className="flex-1" />
|
| 84 |
+
|
| 85 |
+
<div className="flex items-center gap-2">
|
| 86 |
+
{actions ? actions : (
|
| 87 |
+
<div className="flex items-center gap-2">
|
| 88 |
+
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
| 89 |
+
<Bell size={16} />
|
| 90 |
+
</Button>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
+
</div>
|
| 94 |
+
</header>
|
| 95 |
+
)
|
| 96 |
+
}
|
web/src/components/sidebar.tsx
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { usePathname, useSearchParams } from "next/navigation"
|
| 6 |
+
import {
|
| 7 |
+
Waveform,
|
| 8 |
+
Handshake,
|
| 9 |
+
Heart,
|
| 10 |
+
Plus,
|
| 11 |
+
} from "@phosphor-icons/react"
|
| 12 |
+
import Image from "next/image"
|
| 13 |
+
import {
|
| 14 |
+
Sidebar,
|
| 15 |
+
SidebarContent,
|
| 16 |
+
SidebarFooter,
|
| 17 |
+
SidebarGroup,
|
| 18 |
+
SidebarGroupContent,
|
| 19 |
+
SidebarGroupLabel,
|
| 20 |
+
SidebarHeader,
|
| 21 |
+
SidebarMenu,
|
| 22 |
+
SidebarMenuButton,
|
| 23 |
+
SidebarMenuItem,
|
| 24 |
+
} from "@/components/ui/sidebar"
|
| 25 |
+
import {
|
| 26 |
+
listRecentSessions,
|
| 27 |
+
ensureStoreInitialized,
|
| 28 |
+
type RecentSession,
|
| 29 |
+
} from "@/lib/session-store"
|
| 30 |
+
|
| 31 |
+
const PRODUCTS_ITEMS = [
|
| 32 |
+
{ title: "Sales", url: "/sales", icon: Handshake, disabled: true },
|
| 33 |
+
{ title: "Companionship", url: "/companionship", icon: Heart, disabled: true },
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
function formatDuration(s: number) {
|
| 37 |
+
const m = Math.floor(s / 60)
|
| 38 |
+
const sec = Math.floor(s % 60)
|
| 39 |
+
return `${m}:${sec.toString().padStart(2, "0")}`
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function stripExtension(name: string) {
|
| 43 |
+
return name.replace(/\.[^/.]+$/, "")
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function AppSidebar() {
|
| 47 |
+
const pathname = usePathname()
|
| 48 |
+
const searchParams = useSearchParams()
|
| 49 |
+
const currentSessionId = searchParams.get("s")
|
| 50 |
+
const [recent, setRecent] = useState<RecentSession[]>([])
|
| 51 |
+
|
| 52 |
+
// Load and refresh recent sessions whenever pathname changes
|
| 53 |
+
useEffect(() => {
|
| 54 |
+
ensureStoreInitialized()
|
| 55 |
+
setRecent(listRecentSessions())
|
| 56 |
+
}, [pathname])
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<Sidebar>
|
| 60 |
+
<SidebarHeader>
|
| 61 |
+
<div className="flex items-center gap-2 px-2 py-1">
|
| 62 |
+
<Image src="/logo.svg" alt="Ethos Studio" width={32} height={32} className="rounded-lg" />
|
| 63 |
+
<span className="text-sm flex items-center gap-1.5">
|
| 64 |
+
<span className="font-semibold">Ethos</span>
|
| 65 |
+
<span className="text-muted-foreground/40 font-light">|</span>
|
| 66 |
+
<span className="font-medium text-muted-foreground">Studio</span>
|
| 67 |
+
</span>
|
| 68 |
+
</div>
|
| 69 |
+
</SidebarHeader>
|
| 70 |
+
|
| 71 |
+
<SidebarContent>
|
| 72 |
+
{/* Top-level nav */}
|
| 73 |
+
<SidebarGroup>
|
| 74 |
+
<SidebarGroupContent>
|
| 75 |
+
<SidebarMenu>
|
| 76 |
+
<SidebarMenuItem>
|
| 77 |
+
<SidebarMenuButton asChild isActive={pathname === "/"}>
|
| 78 |
+
<Link href="/">
|
| 79 |
+
<Plus />
|
| 80 |
+
<span>Create</span>
|
| 81 |
+
</Link>
|
| 82 |
+
</SidebarMenuButton>
|
| 83 |
+
</SidebarMenuItem>
|
| 84 |
+
</SidebarMenu>
|
| 85 |
+
</SidebarGroupContent>
|
| 86 |
+
</SidebarGroup>
|
| 87 |
+
|
| 88 |
+
{/* Recent Sessions */}
|
| 89 |
+
<SidebarGroup>
|
| 90 |
+
<SidebarGroupLabel>
|
| 91 |
+
Recent
|
| 92 |
+
</SidebarGroupLabel>
|
| 93 |
+
<SidebarGroupContent>
|
| 94 |
+
<SidebarMenu>
|
| 95 |
+
{recent.length === 0 ? (
|
| 96 |
+
<li className="px-2 py-1.5 text-xs text-muted-foreground/60">
|
| 97 |
+
No sessions yet
|
| 98 |
+
</li>
|
| 99 |
+
) : (
|
| 100 |
+
recent.map((session) => {
|
| 101 |
+
const href = `/studio?s=${session.id}`
|
| 102 |
+
return (
|
| 103 |
+
<SidebarMenuItem key={session.id}>
|
| 104 |
+
<SidebarMenuButton
|
| 105 |
+
asChild
|
| 106 |
+
isActive={pathname === "/studio" && currentSessionId === session.id}
|
| 107 |
+
className="h-auto py-1.5 items-start"
|
| 108 |
+
>
|
| 109 |
+
<Link href={href}>
|
| 110 |
+
<Waveform className="mt-0.5 shrink-0" />
|
| 111 |
+
<div className="flex flex-col min-w-0">
|
| 112 |
+
<span className="truncate text-sm leading-snug">
|
| 113 |
+
{stripExtension(session.filename)}
|
| 114 |
+
</span>
|
| 115 |
+
<span className="text-xs text-muted-foreground font-normal">
|
| 116 |
+
{formatDuration(session.duration)} Β· {session.speakerCount} speaker{session.speakerCount !== 1 ? "s" : ""}
|
| 117 |
+
</span>
|
| 118 |
+
</div>
|
| 119 |
+
</Link>
|
| 120 |
+
</SidebarMenuButton>
|
| 121 |
+
</SidebarMenuItem>
|
| 122 |
+
)
|
| 123 |
+
})
|
| 124 |
+
)}
|
| 125 |
+
</SidebarMenu>
|
| 126 |
+
</SidebarGroupContent>
|
| 127 |
+
</SidebarGroup>
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
{/* Products */}
|
| 131 |
+
<SidebarGroup>
|
| 132 |
+
<SidebarGroupLabel>Products</SidebarGroupLabel>
|
| 133 |
+
<SidebarGroupContent>
|
| 134 |
+
<SidebarMenu>
|
| 135 |
+
{PRODUCTS_ITEMS.map((item) => (
|
| 136 |
+
<SidebarMenuItem key={item.title}>
|
| 137 |
+
<SidebarMenuButton
|
| 138 |
+
asChild={!item.disabled}
|
| 139 |
+
isActive={pathname === item.url}
|
| 140 |
+
disabled={item.disabled}
|
| 141 |
+
className={item.disabled ? "opacity-50 cursor-not-allowed" : ""}
|
| 142 |
+
>
|
| 143 |
+
{item.disabled ? (
|
| 144 |
+
<div className="flex items-center gap-2">
|
| 145 |
+
<item.icon />
|
| 146 |
+
<span>{item.title}</span>
|
| 147 |
+
</div>
|
| 148 |
+
) : (
|
| 149 |
+
<Link href={item.url}>
|
| 150 |
+
<item.icon />
|
| 151 |
+
<span>{item.title}</span>
|
| 152 |
+
</Link>
|
| 153 |
+
)}
|
| 154 |
+
</SidebarMenuButton>
|
| 155 |
+
</SidebarMenuItem>
|
| 156 |
+
))}
|
| 157 |
+
</SidebarMenu>
|
| 158 |
+
</SidebarGroupContent>
|
| 159 |
+
</SidebarGroup>
|
| 160 |
+
</SidebarContent>
|
| 161 |
+
|
| 162 |
+
<SidebarFooter>
|
| 163 |
+
<SidebarMenu>
|
| 164 |
+
<SidebarMenuItem>
|
| 165 |
+
<SidebarMenuButton disabled className="opacity-60 cursor-default">
|
| 166 |
+
<span className="flex items-center gap-2 w-full">
|
| 167 |
+
<span>Developer</span>
|
| 168 |
+
<span className="ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded border border-border text-muted-foreground bg-muted leading-none capitalize">
|
| 169 |
+
Coming soon
|
| 170 |
+
</span>
|
| 171 |
+
</span>
|
| 172 |
+
</SidebarMenuButton>
|
| 173 |
+
</SidebarMenuItem>
|
| 174 |
+
</SidebarMenu>
|
| 175 |
+
</SidebarFooter>
|
| 176 |
+
</Sidebar>
|
| 177 |
+
)
|
| 178 |
+
}
|
web/src/components/ui/avatar.tsx
CHANGED
|
@@ -1,112 +1,112 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Avatar as AvatarPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Avatar({
|
| 9 |
-
className,
|
| 10 |
-
size = "default",
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
| 13 |
-
size?: "default" | "sm" | "lg"
|
| 14 |
-
}) {
|
| 15 |
-
return (
|
| 16 |
-
<AvatarPrimitive.Root
|
| 17 |
-
data-slot="avatar"
|
| 18 |
-
data-size={size}
|
| 19 |
-
className={cn(
|
| 20 |
-
"after:border-border group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
| 21 |
-
className
|
| 22 |
-
)}
|
| 23 |
-
{...props}
|
| 24 |
-
/>
|
| 25 |
-
)
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
function AvatarImage({
|
| 29 |
-
className,
|
| 30 |
-
...props
|
| 31 |
-
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 32 |
-
return (
|
| 33 |
-
<AvatarPrimitive.Image
|
| 34 |
-
data-slot="avatar-image"
|
| 35 |
-
className={cn(
|
| 36 |
-
"aspect-square size-full rounded-full object-cover",
|
| 37 |
-
className
|
| 38 |
-
)}
|
| 39 |
-
{...props}
|
| 40 |
-
/>
|
| 41 |
-
)
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
function AvatarFallback({
|
| 45 |
-
className,
|
| 46 |
-
...props
|
| 47 |
-
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 48 |
-
return (
|
| 49 |
-
<AvatarPrimitive.Fallback
|
| 50 |
-
data-slot="avatar-fallback"
|
| 51 |
-
className={cn(
|
| 52 |
-
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
| 53 |
-
className
|
| 54 |
-
)}
|
| 55 |
-
{...props}
|
| 56 |
-
/>
|
| 57 |
-
)
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
| 61 |
-
return (
|
| 62 |
-
<span
|
| 63 |
-
data-slot="avatar-badge"
|
| 64 |
-
className={cn(
|
| 65 |
-
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
| 66 |
-
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
| 67 |
-
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
| 68 |
-
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
| 69 |
-
className
|
| 70 |
-
)}
|
| 71 |
-
{...props}
|
| 72 |
-
/>
|
| 73 |
-
)
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 77 |
-
return (
|
| 78 |
-
<div
|
| 79 |
-
data-slot="avatar-group"
|
| 80 |
-
className={cn(
|
| 81 |
-
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
| 82 |
-
className
|
| 83 |
-
)}
|
| 84 |
-
{...props}
|
| 85 |
-
/>
|
| 86 |
-
)
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
function AvatarGroupCount({
|
| 90 |
-
className,
|
| 91 |
-
...props
|
| 92 |
-
}: React.ComponentProps<"div">) {
|
| 93 |
-
return (
|
| 94 |
-
<div
|
| 95 |
-
data-slot="avatar-group-count"
|
| 96 |
-
className={cn(
|
| 97 |
-
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
| 98 |
-
className
|
| 99 |
-
)}
|
| 100 |
-
{...props}
|
| 101 |
-
/>
|
| 102 |
-
)
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
export {
|
| 106 |
-
Avatar,
|
| 107 |
-
AvatarImage,
|
| 108 |
-
AvatarFallback,
|
| 109 |
-
AvatarGroup,
|
| 110 |
-
AvatarGroupCount,
|
| 111 |
-
AvatarBadge,
|
| 112 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Avatar as AvatarPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Avatar({
|
| 9 |
+
className,
|
| 10 |
+
size = "default",
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
| 13 |
+
size?: "default" | "sm" | "lg"
|
| 14 |
+
}) {
|
| 15 |
+
return (
|
| 16 |
+
<AvatarPrimitive.Root
|
| 17 |
+
data-slot="avatar"
|
| 18 |
+
data-size={size}
|
| 19 |
+
className={cn(
|
| 20 |
+
"after:border-border group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AvatarImage({
|
| 29 |
+
className,
|
| 30 |
+
...props
|
| 31 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 32 |
+
return (
|
| 33 |
+
<AvatarPrimitive.Image
|
| 34 |
+
data-slot="avatar-image"
|
| 35 |
+
className={cn(
|
| 36 |
+
"aspect-square size-full rounded-full object-cover",
|
| 37 |
+
className
|
| 38 |
+
)}
|
| 39 |
+
{...props}
|
| 40 |
+
/>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function AvatarFallback({
|
| 45 |
+
className,
|
| 46 |
+
...props
|
| 47 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 48 |
+
return (
|
| 49 |
+
<AvatarPrimitive.Fallback
|
| 50 |
+
data-slot="avatar-fallback"
|
| 51 |
+
className={cn(
|
| 52 |
+
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
|
| 53 |
+
className
|
| 54 |
+
)}
|
| 55 |
+
{...props}
|
| 56 |
+
/>
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
| 61 |
+
return (
|
| 62 |
+
<span
|
| 63 |
+
data-slot="avatar-badge"
|
| 64 |
+
className={cn(
|
| 65 |
+
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
| 66 |
+
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
| 67 |
+
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
| 68 |
+
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
| 69 |
+
className
|
| 70 |
+
)}
|
| 71 |
+
{...props}
|
| 72 |
+
/>
|
| 73 |
+
)
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 77 |
+
return (
|
| 78 |
+
<div
|
| 79 |
+
data-slot="avatar-group"
|
| 80 |
+
className={cn(
|
| 81 |
+
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
| 82 |
+
className
|
| 83 |
+
)}
|
| 84 |
+
{...props}
|
| 85 |
+
/>
|
| 86 |
+
)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function AvatarGroupCount({
|
| 90 |
+
className,
|
| 91 |
+
...props
|
| 92 |
+
}: React.ComponentProps<"div">) {
|
| 93 |
+
return (
|
| 94 |
+
<div
|
| 95 |
+
data-slot="avatar-group-count"
|
| 96 |
+
className={cn(
|
| 97 |
+
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
| 98 |
+
className
|
| 99 |
+
)}
|
| 100 |
+
{...props}
|
| 101 |
+
/>
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export {
|
| 106 |
+
Avatar,
|
| 107 |
+
AvatarImage,
|
| 108 |
+
AvatarFallback,
|
| 109 |
+
AvatarGroup,
|
| 110 |
+
AvatarGroupCount,
|
| 111 |
+
AvatarBadge,
|
| 112 |
+
}
|
web/src/components/ui/badge.tsx
CHANGED
|
@@ -1,49 +1,49 @@
|
|
| 1 |
-
import * as React from "react"
|
| 2 |
-
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
-
import { Slot } from "radix-ui"
|
| 4 |
-
|
| 5 |
-
import { cn } from "@/lib/utils"
|
| 6 |
-
|
| 7 |
-
const badgeVariants = cva(
|
| 8 |
-
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
| 9 |
-
{
|
| 10 |
-
variants: {
|
| 11 |
-
variant: {
|
| 12 |
-
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
| 13 |
-
secondary:
|
| 14 |
-
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
| 15 |
-
destructive:
|
| 16 |
-
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
| 17 |
-
outline:
|
| 18 |
-
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30",
|
| 19 |
-
ghost:
|
| 20 |
-
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
| 21 |
-
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
-
},
|
| 23 |
-
},
|
| 24 |
-
defaultVariants: {
|
| 25 |
-
variant: "default",
|
| 26 |
-
},
|
| 27 |
-
}
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
function Badge({
|
| 31 |
-
className,
|
| 32 |
-
variant = "default",
|
| 33 |
-
asChild = false,
|
| 34 |
-
...props
|
| 35 |
-
}: React.ComponentProps<"span"> &
|
| 36 |
-
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 37 |
-
const Comp = asChild ? Slot.Root : "span"
|
| 38 |
-
|
| 39 |
-
return (
|
| 40 |
-
<Comp
|
| 41 |
-
data-slot="badge"
|
| 42 |
-
data-variant={variant}
|
| 43 |
-
className={cn(badgeVariants({ variant }), className)}
|
| 44 |
-
{...props}
|
| 45 |
-
/>
|
| 46 |
-
)
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
export { Badge, badgeVariants }
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { Slot } from "radix-ui"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const badgeVariants = cva(
|
| 8 |
+
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
| 13 |
+
secondary:
|
| 14 |
+
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
| 15 |
+
destructive:
|
| 16 |
+
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
| 17 |
+
outline:
|
| 18 |
+
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30",
|
| 19 |
+
ghost:
|
| 20 |
+
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
defaultVariants: {
|
| 25 |
+
variant: "default",
|
| 26 |
+
},
|
| 27 |
+
}
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
function Badge({
|
| 31 |
+
className,
|
| 32 |
+
variant = "default",
|
| 33 |
+
asChild = false,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<"span"> &
|
| 36 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 37 |
+
const Comp = asChild ? Slot.Root : "span"
|
| 38 |
+
|
| 39 |
+
return (
|
| 40 |
+
<Comp
|
| 41 |
+
data-slot="badge"
|
| 42 |
+
data-variant={variant}
|
| 43 |
+
className={cn(badgeVariants({ variant }), className)}
|
| 44 |
+
{...props}
|
| 45 |
+
/>
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export { Badge, badgeVariants }
|
web/src/components/ui/button.tsx
CHANGED
|
@@ -1,65 +1,65 @@
|
|
| 1 |
-
import * as React from "react"
|
| 2 |
-
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
-
import { Slot } from "radix-ui"
|
| 4 |
-
|
| 5 |
-
import { cn } from "@/lib/utils"
|
| 6 |
-
|
| 7 |
-
const buttonVariants = cva(
|
| 8 |
-
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
| 9 |
-
{
|
| 10 |
-
variants: {
|
| 11 |
-
variant: {
|
| 12 |
-
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
| 13 |
-
outline:
|
| 14 |
-
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
| 15 |
-
secondary:
|
| 16 |
-
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
| 17 |
-
ghost:
|
| 18 |
-
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
| 19 |
-
destructive:
|
| 20 |
-
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
| 21 |
-
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
-
},
|
| 23 |
-
size: {
|
| 24 |
-
default:
|
| 25 |
-
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
| 26 |
-
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
| 27 |
-
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 28 |
-
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
| 29 |
-
icon: "size-9",
|
| 30 |
-
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
| 31 |
-
"icon-sm": "size-8",
|
| 32 |
-
"icon-lg": "size-10",
|
| 33 |
-
},
|
| 34 |
-
},
|
| 35 |
-
defaultVariants: {
|
| 36 |
-
variant: "default",
|
| 37 |
-
size: "default",
|
| 38 |
-
},
|
| 39 |
-
}
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
function Button({
|
| 43 |
-
className,
|
| 44 |
-
variant = "default",
|
| 45 |
-
size = "default",
|
| 46 |
-
asChild = false,
|
| 47 |
-
...props
|
| 48 |
-
}: React.ComponentProps<"button"> &
|
| 49 |
-
VariantProps<typeof buttonVariants> & {
|
| 50 |
-
asChild?: boolean
|
| 51 |
-
}) {
|
| 52 |
-
const Comp = asChild ? Slot.Root : "button"
|
| 53 |
-
|
| 54 |
-
return (
|
| 55 |
-
<Comp
|
| 56 |
-
data-slot="button"
|
| 57 |
-
data-variant={variant}
|
| 58 |
-
data-size={size}
|
| 59 |
-
className={cn(buttonVariants({ variant, size, className }))}
|
| 60 |
-
{...props}
|
| 61 |
-
/>
|
| 62 |
-
)
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
export { Button, buttonVariants }
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { Slot } from "radix-ui"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-4xl border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
| 13 |
+
outline:
|
| 14 |
+
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
| 15 |
+
secondary:
|
| 16 |
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
| 17 |
+
ghost:
|
| 18 |
+
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
| 19 |
+
destructive:
|
| 20 |
+
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
| 21 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 22 |
+
},
|
| 23 |
+
size: {
|
| 24 |
+
default:
|
| 25 |
+
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
| 26 |
+
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
| 27 |
+
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
| 28 |
+
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
| 29 |
+
icon: "size-9",
|
| 30 |
+
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
| 31 |
+
"icon-sm": "size-8",
|
| 32 |
+
"icon-lg": "size-10",
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
defaultVariants: {
|
| 36 |
+
variant: "default",
|
| 37 |
+
size: "default",
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
function Button({
|
| 43 |
+
className,
|
| 44 |
+
variant = "default",
|
| 45 |
+
size = "default",
|
| 46 |
+
asChild = false,
|
| 47 |
+
...props
|
| 48 |
+
}: React.ComponentProps<"button"> &
|
| 49 |
+
VariantProps<typeof buttonVariants> & {
|
| 50 |
+
asChild?: boolean
|
| 51 |
+
}) {
|
| 52 |
+
const Comp = asChild ? Slot.Root : "button"
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<Comp
|
| 56 |
+
data-slot="button"
|
| 57 |
+
data-variant={variant}
|
| 58 |
+
data-size={size}
|
| 59 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export { Button, buttonVariants }
|
web/src/components/ui/card.tsx
CHANGED
|
@@ -1,100 +1,100 @@
|
|
| 1 |
-
import * as React from "react"
|
| 2 |
-
|
| 3 |
-
import { cn } from "@/lib/utils"
|
| 4 |
-
|
| 5 |
-
function Card({
|
| 6 |
-
className,
|
| 7 |
-
size = "default",
|
| 8 |
-
...props
|
| 9 |
-
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
| 10 |
-
return (
|
| 11 |
-
<div
|
| 12 |
-
data-slot="card"
|
| 13 |
-
data-size={size}
|
| 14 |
-
className={cn(
|
| 15 |
-
"ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-6 overflow-hidden rounded-2xl py-6 text-sm ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
| 16 |
-
className
|
| 17 |
-
)}
|
| 18 |
-
{...props}
|
| 19 |
-
/>
|
| 20 |
-
)
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 24 |
-
return (
|
| 25 |
-
<div
|
| 26 |
-
data-slot="card-header"
|
| 27 |
-
className={cn(
|
| 28 |
-
"group/card-header @container/card-header grid auto-rows-min items-start gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
|
| 29 |
-
className
|
| 30 |
-
)}
|
| 31 |
-
{...props}
|
| 32 |
-
/>
|
| 33 |
-
)
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 37 |
-
return (
|
| 38 |
-
<div
|
| 39 |
-
data-slot="card-title"
|
| 40 |
-
className={cn("text-base font-medium", className)}
|
| 41 |
-
{...props}
|
| 42 |
-
/>
|
| 43 |
-
)
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 47 |
-
return (
|
| 48 |
-
<div
|
| 49 |
-
data-slot="card-description"
|
| 50 |
-
className={cn("text-muted-foreground text-sm", className)}
|
| 51 |
-
{...props}
|
| 52 |
-
/>
|
| 53 |
-
)
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 57 |
-
return (
|
| 58 |
-
<div
|
| 59 |
-
data-slot="card-action"
|
| 60 |
-
className={cn(
|
| 61 |
-
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 62 |
-
className
|
| 63 |
-
)}
|
| 64 |
-
{...props}
|
| 65 |
-
/>
|
| 66 |
-
)
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 70 |
-
return (
|
| 71 |
-
<div
|
| 72 |
-
data-slot="card-content"
|
| 73 |
-
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
| 74 |
-
{...props}
|
| 75 |
-
/>
|
| 76 |
-
)
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 80 |
-
return (
|
| 81 |
-
<div
|
| 82 |
-
data-slot="card-footer"
|
| 83 |
-
className={cn(
|
| 84 |
-
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
|
| 85 |
-
className
|
| 86 |
-
)}
|
| 87 |
-
{...props}
|
| 88 |
-
/>
|
| 89 |
-
)
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
export {
|
| 93 |
-
Card,
|
| 94 |
-
CardHeader,
|
| 95 |
-
CardFooter,
|
| 96 |
-
CardTitle,
|
| 97 |
-
CardAction,
|
| 98 |
-
CardDescription,
|
| 99 |
-
CardContent,
|
| 100 |
-
}
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Card({
|
| 6 |
+
className,
|
| 7 |
+
size = "default",
|
| 8 |
+
...props
|
| 9 |
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
| 10 |
+
return (
|
| 11 |
+
<div
|
| 12 |
+
data-slot="card"
|
| 13 |
+
data-size={size}
|
| 14 |
+
className={cn(
|
| 15 |
+
"ring-foreground/10 bg-card text-card-foreground group/card flex flex-col gap-6 overflow-hidden rounded-2xl py-6 text-sm ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
| 16 |
+
className
|
| 17 |
+
)}
|
| 18 |
+
{...props}
|
| 19 |
+
/>
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 24 |
+
return (
|
| 25 |
+
<div
|
| 26 |
+
data-slot="card-header"
|
| 27 |
+
className={cn(
|
| 28 |
+
"group/card-header @container/card-header grid auto-rows-min items-start gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
|
| 29 |
+
className
|
| 30 |
+
)}
|
| 31 |
+
{...props}
|
| 32 |
+
/>
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 37 |
+
return (
|
| 38 |
+
<div
|
| 39 |
+
data-slot="card-title"
|
| 40 |
+
className={cn("text-base font-medium", className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 47 |
+
return (
|
| 48 |
+
<div
|
| 49 |
+
data-slot="card-description"
|
| 50 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 51 |
+
{...props}
|
| 52 |
+
/>
|
| 53 |
+
)
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 57 |
+
return (
|
| 58 |
+
<div
|
| 59 |
+
data-slot="card-action"
|
| 60 |
+
className={cn(
|
| 61 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 62 |
+
className
|
| 63 |
+
)}
|
| 64 |
+
{...props}
|
| 65 |
+
/>
|
| 66 |
+
)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div
|
| 72 |
+
data-slot="card-content"
|
| 73 |
+
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 80 |
+
return (
|
| 81 |
+
<div
|
| 82 |
+
data-slot="card-footer"
|
| 83 |
+
className={cn(
|
| 84 |
+
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
|
| 85 |
+
className
|
| 86 |
+
)}
|
| 87 |
+
{...props}
|
| 88 |
+
/>
|
| 89 |
+
)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export {
|
| 93 |
+
Card,
|
| 94 |
+
CardHeader,
|
| 95 |
+
CardFooter,
|
| 96 |
+
CardTitle,
|
| 97 |
+
CardAction,
|
| 98 |
+
CardDescription,
|
| 99 |
+
CardContent,
|
| 100 |
+
}
|
web/src/components/ui/collapsible.tsx
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
| 4 |
-
|
| 5 |
-
function Collapsible({
|
| 6 |
-
...props
|
| 7 |
-
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
| 8 |
-
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
function CollapsibleTrigger({
|
| 12 |
-
...props
|
| 13 |
-
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
| 14 |
-
return (
|
| 15 |
-
<CollapsiblePrimitive.CollapsibleTrigger
|
| 16 |
-
data-slot="collapsible-trigger"
|
| 17 |
-
{...props}
|
| 18 |
-
/>
|
| 19 |
-
)
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function CollapsibleContent({
|
| 23 |
-
...props
|
| 24 |
-
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
| 25 |
-
return (
|
| 26 |
-
<CollapsiblePrimitive.CollapsibleContent
|
| 27 |
-
data-slot="collapsible-content"
|
| 28 |
-
{...props}
|
| 29 |
-
/>
|
| 30 |
-
)
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
| 4 |
+
|
| 5 |
+
function Collapsible({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
| 8 |
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function CollapsibleTrigger({
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
| 14 |
+
return (
|
| 15 |
+
<CollapsiblePrimitive.CollapsibleTrigger
|
| 16 |
+
data-slot="collapsible-trigger"
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function CollapsibleContent({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
| 25 |
+
return (
|
| 26 |
+
<CollapsiblePrimitive.CollapsibleContent
|
| 27 |
+
data-slot="collapsible-content"
|
| 28 |
+
{...props}
|
| 29 |
+
/>
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
web/src/components/ui/dialog.tsx
CHANGED
|
@@ -1,165 +1,165 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Dialog as DialogPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
import { Button } from "@/components/ui/button"
|
| 8 |
-
import { XIcon } from "lucide-react"
|
| 9 |
-
|
| 10 |
-
function Dialog({
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 13 |
-
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
function DialogTrigger({
|
| 17 |
-
...props
|
| 18 |
-
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 19 |
-
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function DialogPortal({
|
| 23 |
-
...props
|
| 24 |
-
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 25 |
-
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
function DialogClose({
|
| 29 |
-
...props
|
| 30 |
-
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
| 31 |
-
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
function DialogOverlay({
|
| 35 |
-
className,
|
| 36 |
-
...props
|
| 37 |
-
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
| 38 |
-
return (
|
| 39 |
-
<DialogPrimitive.Overlay
|
| 40 |
-
data-slot="dialog-overlay"
|
| 41 |
-
className={cn(
|
| 42 |
-
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs",
|
| 43 |
-
className
|
| 44 |
-
)}
|
| 45 |
-
{...props}
|
| 46 |
-
/>
|
| 47 |
-
)
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
function DialogContent({
|
| 51 |
-
className,
|
| 52 |
-
children,
|
| 53 |
-
showCloseButton = true,
|
| 54 |
-
...props
|
| 55 |
-
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
| 56 |
-
showCloseButton?: boolean
|
| 57 |
-
}) {
|
| 58 |
-
return (
|
| 59 |
-
<DialogPortal>
|
| 60 |
-
<DialogOverlay />
|
| 61 |
-
<DialogPrimitive.Content
|
| 62 |
-
data-slot="dialog-content"
|
| 63 |
-
className={cn(
|
| 64 |
-
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 outline-none sm:max-w-md",
|
| 65 |
-
className
|
| 66 |
-
)}
|
| 67 |
-
{...props}
|
| 68 |
-
>
|
| 69 |
-
{children}
|
| 70 |
-
{showCloseButton && (
|
| 71 |
-
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
| 72 |
-
<Button
|
| 73 |
-
variant="ghost"
|
| 74 |
-
className="absolute top-4 right-4"
|
| 75 |
-
size="icon-sm"
|
| 76 |
-
>
|
| 77 |
-
<XIcon
|
| 78 |
-
/>
|
| 79 |
-
<span className="sr-only">Close</span>
|
| 80 |
-
</Button>
|
| 81 |
-
</DialogPrimitive.Close>
|
| 82 |
-
)}
|
| 83 |
-
</DialogPrimitive.Content>
|
| 84 |
-
</DialogPortal>
|
| 85 |
-
)
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 89 |
-
return (
|
| 90 |
-
<div
|
| 91 |
-
data-slot="dialog-header"
|
| 92 |
-
className={cn("flex flex-col gap-2", className)}
|
| 93 |
-
{...props}
|
| 94 |
-
/>
|
| 95 |
-
)
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
function DialogFooter({
|
| 99 |
-
className,
|
| 100 |
-
showCloseButton = false,
|
| 101 |
-
children,
|
| 102 |
-
...props
|
| 103 |
-
}: React.ComponentProps<"div"> & {
|
| 104 |
-
showCloseButton?: boolean
|
| 105 |
-
}) {
|
| 106 |
-
return (
|
| 107 |
-
<div
|
| 108 |
-
data-slot="dialog-footer"
|
| 109 |
-
className={cn(
|
| 110 |
-
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 111 |
-
className
|
| 112 |
-
)}
|
| 113 |
-
{...props}
|
| 114 |
-
>
|
| 115 |
-
{children}
|
| 116 |
-
{showCloseButton && (
|
| 117 |
-
<DialogPrimitive.Close asChild>
|
| 118 |
-
<Button variant="outline">Close</Button>
|
| 119 |
-
</DialogPrimitive.Close>
|
| 120 |
-
)}
|
| 121 |
-
</div>
|
| 122 |
-
)
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
function DialogTitle({
|
| 126 |
-
className,
|
| 127 |
-
...props
|
| 128 |
-
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 129 |
-
return (
|
| 130 |
-
<DialogPrimitive.Title
|
| 131 |
-
data-slot="dialog-title"
|
| 132 |
-
className={cn("text-base leading-none font-medium", className)}
|
| 133 |
-
{...props}
|
| 134 |
-
/>
|
| 135 |
-
)
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
function DialogDescription({
|
| 139 |
-
className,
|
| 140 |
-
...props
|
| 141 |
-
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 142 |
-
return (
|
| 143 |
-
<DialogPrimitive.Description
|
| 144 |
-
data-slot="dialog-description"
|
| 145 |
-
className={cn(
|
| 146 |
-
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
|
| 147 |
-
className
|
| 148 |
-
)}
|
| 149 |
-
{...props}
|
| 150 |
-
/>
|
| 151 |
-
)
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
export {
|
| 155 |
-
Dialog,
|
| 156 |
-
DialogClose,
|
| 157 |
-
DialogContent,
|
| 158 |
-
DialogDescription,
|
| 159 |
-
DialogFooter,
|
| 160 |
-
DialogHeader,
|
| 161 |
-
DialogOverlay,
|
| 162 |
-
DialogPortal,
|
| 163 |
-
DialogTitle,
|
| 164 |
-
DialogTrigger,
|
| 165 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Dialog as DialogPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { XIcon } from "lucide-react"
|
| 9 |
+
|
| 10 |
+
function Dialog({
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 13 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function DialogTrigger({
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 19 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function DialogPortal({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 25 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function DialogClose({
|
| 29 |
+
...props
|
| 30 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
| 31 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function DialogOverlay({
|
| 35 |
+
className,
|
| 36 |
+
...props
|
| 37 |
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
| 38 |
+
return (
|
| 39 |
+
<DialogPrimitive.Overlay
|
| 40 |
+
data-slot="dialog-overlay"
|
| 41 |
+
className={cn(
|
| 42 |
+
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function DialogContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
showCloseButton = true,
|
| 54 |
+
...props
|
| 55 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
| 56 |
+
showCloseButton?: boolean
|
| 57 |
+
}) {
|
| 58 |
+
return (
|
| 59 |
+
<DialogPortal>
|
| 60 |
+
<DialogOverlay />
|
| 61 |
+
<DialogPrimitive.Content
|
| 62 |
+
data-slot="dialog-content"
|
| 63 |
+
className={cn(
|
| 64 |
+
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/5 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 outline-none sm:max-w-md",
|
| 65 |
+
className
|
| 66 |
+
)}
|
| 67 |
+
{...props}
|
| 68 |
+
>
|
| 69 |
+
{children}
|
| 70 |
+
{showCloseButton && (
|
| 71 |
+
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
| 72 |
+
<Button
|
| 73 |
+
variant="ghost"
|
| 74 |
+
className="absolute top-4 right-4"
|
| 75 |
+
size="icon-sm"
|
| 76 |
+
>
|
| 77 |
+
<XIcon
|
| 78 |
+
/>
|
| 79 |
+
<span className="sr-only">Close</span>
|
| 80 |
+
</Button>
|
| 81 |
+
</DialogPrimitive.Close>
|
| 82 |
+
)}
|
| 83 |
+
</DialogPrimitive.Content>
|
| 84 |
+
</DialogPortal>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 89 |
+
return (
|
| 90 |
+
<div
|
| 91 |
+
data-slot="dialog-header"
|
| 92 |
+
className={cn("flex flex-col gap-2", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
/>
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function DialogFooter({
|
| 99 |
+
className,
|
| 100 |
+
showCloseButton = false,
|
| 101 |
+
children,
|
| 102 |
+
...props
|
| 103 |
+
}: React.ComponentProps<"div"> & {
|
| 104 |
+
showCloseButton?: boolean
|
| 105 |
+
}) {
|
| 106 |
+
return (
|
| 107 |
+
<div
|
| 108 |
+
data-slot="dialog-footer"
|
| 109 |
+
className={cn(
|
| 110 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 111 |
+
className
|
| 112 |
+
)}
|
| 113 |
+
{...props}
|
| 114 |
+
>
|
| 115 |
+
{children}
|
| 116 |
+
{showCloseButton && (
|
| 117 |
+
<DialogPrimitive.Close asChild>
|
| 118 |
+
<Button variant="outline">Close</Button>
|
| 119 |
+
</DialogPrimitive.Close>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function DialogTitle({
|
| 126 |
+
className,
|
| 127 |
+
...props
|
| 128 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 129 |
+
return (
|
| 130 |
+
<DialogPrimitive.Title
|
| 131 |
+
data-slot="dialog-title"
|
| 132 |
+
className={cn("text-base leading-none font-medium", className)}
|
| 133 |
+
{...props}
|
| 134 |
+
/>
|
| 135 |
+
)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function DialogDescription({
|
| 139 |
+
className,
|
| 140 |
+
...props
|
| 141 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 142 |
+
return (
|
| 143 |
+
<DialogPrimitive.Description
|
| 144 |
+
data-slot="dialog-description"
|
| 145 |
+
className={cn(
|
| 146 |
+
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
|
| 147 |
+
className
|
| 148 |
+
)}
|
| 149 |
+
{...props}
|
| 150 |
+
/>
|
| 151 |
+
)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export {
|
| 155 |
+
Dialog,
|
| 156 |
+
DialogClose,
|
| 157 |
+
DialogContent,
|
| 158 |
+
DialogDescription,
|
| 159 |
+
DialogFooter,
|
| 160 |
+
DialogHeader,
|
| 161 |
+
DialogOverlay,
|
| 162 |
+
DialogPortal,
|
| 163 |
+
DialogTitle,
|
| 164 |
+
DialogTrigger,
|
| 165 |
+
}
|
web/src/components/ui/dropdown-menu.tsx
CHANGED
|
@@ -1,269 +1,269 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
| 8 |
-
|
| 9 |
-
function DropdownMenu({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
| 12 |
-
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
function DropdownMenuPortal({
|
| 16 |
-
...props
|
| 17 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
| 18 |
-
return (
|
| 19 |
-
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
| 20 |
-
)
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
function DropdownMenuTrigger({
|
| 24 |
-
...props
|
| 25 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
| 26 |
-
return (
|
| 27 |
-
<DropdownMenuPrimitive.Trigger
|
| 28 |
-
data-slot="dropdown-menu-trigger"
|
| 29 |
-
{...props}
|
| 30 |
-
/>
|
| 31 |
-
)
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
function DropdownMenuContent({
|
| 35 |
-
className,
|
| 36 |
-
align = "start",
|
| 37 |
-
sideOffset = 4,
|
| 38 |
-
...props
|
| 39 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
| 40 |
-
return (
|
| 41 |
-
<DropdownMenuPrimitive.Portal>
|
| 42 |
-
<DropdownMenuPrimitive.Content
|
| 43 |
-
data-slot="dropdown-menu-content"
|
| 44 |
-
sideOffset={sideOffset}
|
| 45 |
-
align={align}
|
| 46 |
-
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl p-1 shadow-2xl ring-1 duration-100 data-[state=closed]:overflow-hidden", className )}
|
| 47 |
-
{...props}
|
| 48 |
-
/>
|
| 49 |
-
</DropdownMenuPrimitive.Portal>
|
| 50 |
-
)
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
function DropdownMenuGroup({
|
| 54 |
-
...props
|
| 55 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
| 56 |
-
return (
|
| 57 |
-
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
| 58 |
-
)
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
function DropdownMenuItem({
|
| 62 |
-
className,
|
| 63 |
-
inset,
|
| 64 |
-
variant = "default",
|
| 65 |
-
...props
|
| 66 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
| 67 |
-
inset?: boolean
|
| 68 |
-
variant?: "default" | "destructive"
|
| 69 |
-
}) {
|
| 70 |
-
return (
|
| 71 |
-
<DropdownMenuPrimitive.Item
|
| 72 |
-
data-slot="dropdown-menu-item"
|
| 73 |
-
data-inset={inset}
|
| 74 |
-
data-variant={variant}
|
| 75 |
-
className={cn(
|
| 76 |
-
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 77 |
-
className
|
| 78 |
-
)}
|
| 79 |
-
{...props}
|
| 80 |
-
/>
|
| 81 |
-
)
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
function DropdownMenuCheckboxItem({
|
| 85 |
-
className,
|
| 86 |
-
children,
|
| 87 |
-
checked,
|
| 88 |
-
inset,
|
| 89 |
-
...props
|
| 90 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
| 91 |
-
inset?: boolean
|
| 92 |
-
}) {
|
| 93 |
-
return (
|
| 94 |
-
<DropdownMenuPrimitive.CheckboxItem
|
| 95 |
-
data-slot="dropdown-menu-checkbox-item"
|
| 96 |
-
data-inset={inset}
|
| 97 |
-
className={cn(
|
| 98 |
-
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 99 |
-
className
|
| 100 |
-
)}
|
| 101 |
-
checked={checked}
|
| 102 |
-
{...props}
|
| 103 |
-
>
|
| 104 |
-
<span
|
| 105 |
-
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
| 106 |
-
data-slot="dropdown-menu-checkbox-item-indicator"
|
| 107 |
-
>
|
| 108 |
-
<DropdownMenuPrimitive.ItemIndicator>
|
| 109 |
-
<CheckIcon
|
| 110 |
-
/>
|
| 111 |
-
</DropdownMenuPrimitive.ItemIndicator>
|
| 112 |
-
</span>
|
| 113 |
-
{children}
|
| 114 |
-
</DropdownMenuPrimitive.CheckboxItem>
|
| 115 |
-
)
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
function DropdownMenuRadioGroup({
|
| 119 |
-
...props
|
| 120 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
| 121 |
-
return (
|
| 122 |
-
<DropdownMenuPrimitive.RadioGroup
|
| 123 |
-
data-slot="dropdown-menu-radio-group"
|
| 124 |
-
{...props}
|
| 125 |
-
/>
|
| 126 |
-
)
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
function DropdownMenuRadioItem({
|
| 130 |
-
className,
|
| 131 |
-
children,
|
| 132 |
-
inset,
|
| 133 |
-
...props
|
| 134 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
| 135 |
-
inset?: boolean
|
| 136 |
-
}) {
|
| 137 |
-
return (
|
| 138 |
-
<DropdownMenuPrimitive.RadioItem
|
| 139 |
-
data-slot="dropdown-menu-radio-item"
|
| 140 |
-
data-inset={inset}
|
| 141 |
-
className={cn(
|
| 142 |
-
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 143 |
-
className
|
| 144 |
-
)}
|
| 145 |
-
{...props}
|
| 146 |
-
>
|
| 147 |
-
<span
|
| 148 |
-
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
| 149 |
-
data-slot="dropdown-menu-radio-item-indicator"
|
| 150 |
-
>
|
| 151 |
-
<DropdownMenuPrimitive.ItemIndicator>
|
| 152 |
-
<CheckIcon
|
| 153 |
-
/>
|
| 154 |
-
</DropdownMenuPrimitive.ItemIndicator>
|
| 155 |
-
</span>
|
| 156 |
-
{children}
|
| 157 |
-
</DropdownMenuPrimitive.RadioItem>
|
| 158 |
-
)
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
function DropdownMenuLabel({
|
| 162 |
-
className,
|
| 163 |
-
inset,
|
| 164 |
-
...props
|
| 165 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
| 166 |
-
inset?: boolean
|
| 167 |
-
}) {
|
| 168 |
-
return (
|
| 169 |
-
<DropdownMenuPrimitive.Label
|
| 170 |
-
data-slot="dropdown-menu-label"
|
| 171 |
-
data-inset={inset}
|
| 172 |
-
className={cn(
|
| 173 |
-
"text-muted-foreground px-3 py-2.5 text-xs data-inset:pl-9.5",
|
| 174 |
-
className
|
| 175 |
-
)}
|
| 176 |
-
{...props}
|
| 177 |
-
/>
|
| 178 |
-
)
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
function DropdownMenuSeparator({
|
| 182 |
-
className,
|
| 183 |
-
...props
|
| 184 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
| 185 |
-
return (
|
| 186 |
-
<DropdownMenuPrimitive.Separator
|
| 187 |
-
data-slot="dropdown-menu-separator"
|
| 188 |
-
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
|
| 189 |
-
{...props}
|
| 190 |
-
/>
|
| 191 |
-
)
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
function DropdownMenuShortcut({
|
| 195 |
-
className,
|
| 196 |
-
...props
|
| 197 |
-
}: React.ComponentProps<"span">) {
|
| 198 |
-
return (
|
| 199 |
-
<span
|
| 200 |
-
data-slot="dropdown-menu-shortcut"
|
| 201 |
-
className={cn(
|
| 202 |
-
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
|
| 203 |
-
className
|
| 204 |
-
)}
|
| 205 |
-
{...props}
|
| 206 |
-
/>
|
| 207 |
-
)
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
function DropdownMenuSub({
|
| 211 |
-
...props
|
| 212 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
| 213 |
-
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
function DropdownMenuSubTrigger({
|
| 217 |
-
className,
|
| 218 |
-
inset,
|
| 219 |
-
children,
|
| 220 |
-
...props
|
| 221 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
| 222 |
-
inset?: boolean
|
| 223 |
-
}) {
|
| 224 |
-
return (
|
| 225 |
-
<DropdownMenuPrimitive.SubTrigger
|
| 226 |
-
data-slot="dropdown-menu-sub-trigger"
|
| 227 |
-
data-inset={inset}
|
| 228 |
-
className={cn(
|
| 229 |
-
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm outline-hidden select-none data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 230 |
-
className
|
| 231 |
-
)}
|
| 232 |
-
{...props}
|
| 233 |
-
>
|
| 234 |
-
{children}
|
| 235 |
-
<ChevronRightIcon className="ml-auto" />
|
| 236 |
-
</DropdownMenuPrimitive.SubTrigger>
|
| 237 |
-
)
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
function DropdownMenuSubContent({
|
| 241 |
-
className,
|
| 242 |
-
...props
|
| 243 |
-
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
| 244 |
-
return (
|
| 245 |
-
<DropdownMenuPrimitive.SubContent
|
| 246 |
-
data-slot="dropdown-menu-sub-content"
|
| 247 |
-
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-2xl p-1 shadow-2xl ring-1 duration-100", className )}
|
| 248 |
-
{...props}
|
| 249 |
-
/>
|
| 250 |
-
)
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
export {
|
| 254 |
-
DropdownMenu,
|
| 255 |
-
DropdownMenuPortal,
|
| 256 |
-
DropdownMenuTrigger,
|
| 257 |
-
DropdownMenuContent,
|
| 258 |
-
DropdownMenuGroup,
|
| 259 |
-
DropdownMenuLabel,
|
| 260 |
-
DropdownMenuItem,
|
| 261 |
-
DropdownMenuCheckboxItem,
|
| 262 |
-
DropdownMenuRadioGroup,
|
| 263 |
-
DropdownMenuRadioItem,
|
| 264 |
-
DropdownMenuSeparator,
|
| 265 |
-
DropdownMenuShortcut,
|
| 266 |
-
DropdownMenuSub,
|
| 267 |
-
DropdownMenuSubTrigger,
|
| 268 |
-
DropdownMenuSubContent,
|
| 269 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
| 8 |
+
|
| 9 |
+
function DropdownMenu({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
| 12 |
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function DropdownMenuPortal({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function DropdownMenuTrigger({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
| 26 |
+
return (
|
| 27 |
+
<DropdownMenuPrimitive.Trigger
|
| 28 |
+
data-slot="dropdown-menu-trigger"
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function DropdownMenuContent({
|
| 35 |
+
className,
|
| 36 |
+
align = "start",
|
| 37 |
+
sideOffset = 4,
|
| 38 |
+
...props
|
| 39 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
| 40 |
+
return (
|
| 41 |
+
<DropdownMenuPrimitive.Portal>
|
| 42 |
+
<DropdownMenuPrimitive.Content
|
| 43 |
+
data-slot="dropdown-menu-content"
|
| 44 |
+
sideOffset={sideOffset}
|
| 45 |
+
align={align}
|
| 46 |
+
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl p-1 shadow-2xl ring-1 duration-100 data-[state=closed]:overflow-hidden", className )}
|
| 47 |
+
{...props}
|
| 48 |
+
/>
|
| 49 |
+
</DropdownMenuPrimitive.Portal>
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function DropdownMenuGroup({
|
| 54 |
+
...props
|
| 55 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
| 56 |
+
return (
|
| 57 |
+
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
| 58 |
+
)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function DropdownMenuItem({
|
| 62 |
+
className,
|
| 63 |
+
inset,
|
| 64 |
+
variant = "default",
|
| 65 |
+
...props
|
| 66 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
| 67 |
+
inset?: boolean
|
| 68 |
+
variant?: "default" | "destructive"
|
| 69 |
+
}) {
|
| 70 |
+
return (
|
| 71 |
+
<DropdownMenuPrimitive.Item
|
| 72 |
+
data-slot="dropdown-menu-item"
|
| 73 |
+
data-inset={inset}
|
| 74 |
+
data-variant={variant}
|
| 75 |
+
className={cn(
|
| 76 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 77 |
+
className
|
| 78 |
+
)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function DropdownMenuCheckboxItem({
|
| 85 |
+
className,
|
| 86 |
+
children,
|
| 87 |
+
checked,
|
| 88 |
+
inset,
|
| 89 |
+
...props
|
| 90 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
| 91 |
+
inset?: boolean
|
| 92 |
+
}) {
|
| 93 |
+
return (
|
| 94 |
+
<DropdownMenuPrimitive.CheckboxItem
|
| 95 |
+
data-slot="dropdown-menu-checkbox-item"
|
| 96 |
+
data-inset={inset}
|
| 97 |
+
className={cn(
|
| 98 |
+
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 99 |
+
className
|
| 100 |
+
)}
|
| 101 |
+
checked={checked}
|
| 102 |
+
{...props}
|
| 103 |
+
>
|
| 104 |
+
<span
|
| 105 |
+
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
| 106 |
+
data-slot="dropdown-menu-checkbox-item-indicator"
|
| 107 |
+
>
|
| 108 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 109 |
+
<CheckIcon
|
| 110 |
+
/>
|
| 111 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 112 |
+
</span>
|
| 113 |
+
{children}
|
| 114 |
+
</DropdownMenuPrimitive.CheckboxItem>
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function DropdownMenuRadioGroup({
|
| 119 |
+
...props
|
| 120 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
| 121 |
+
return (
|
| 122 |
+
<DropdownMenuPrimitive.RadioGroup
|
| 123 |
+
data-slot="dropdown-menu-radio-group"
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function DropdownMenuRadioItem({
|
| 130 |
+
className,
|
| 131 |
+
children,
|
| 132 |
+
inset,
|
| 133 |
+
...props
|
| 134 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
| 135 |
+
inset?: boolean
|
| 136 |
+
}) {
|
| 137 |
+
return (
|
| 138 |
+
<DropdownMenuPrimitive.RadioItem
|
| 139 |
+
data-slot="dropdown-menu-radio-item"
|
| 140 |
+
data-inset={inset}
|
| 141 |
+
className={cn(
|
| 142 |
+
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 143 |
+
className
|
| 144 |
+
)}
|
| 145 |
+
{...props}
|
| 146 |
+
>
|
| 147 |
+
<span
|
| 148 |
+
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
| 149 |
+
data-slot="dropdown-menu-radio-item-indicator"
|
| 150 |
+
>
|
| 151 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 152 |
+
<CheckIcon
|
| 153 |
+
/>
|
| 154 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 155 |
+
</span>
|
| 156 |
+
{children}
|
| 157 |
+
</DropdownMenuPrimitive.RadioItem>
|
| 158 |
+
)
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function DropdownMenuLabel({
|
| 162 |
+
className,
|
| 163 |
+
inset,
|
| 164 |
+
...props
|
| 165 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
| 166 |
+
inset?: boolean
|
| 167 |
+
}) {
|
| 168 |
+
return (
|
| 169 |
+
<DropdownMenuPrimitive.Label
|
| 170 |
+
data-slot="dropdown-menu-label"
|
| 171 |
+
data-inset={inset}
|
| 172 |
+
className={cn(
|
| 173 |
+
"text-muted-foreground px-3 py-2.5 text-xs data-inset:pl-9.5",
|
| 174 |
+
className
|
| 175 |
+
)}
|
| 176 |
+
{...props}
|
| 177 |
+
/>
|
| 178 |
+
)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
function DropdownMenuSeparator({
|
| 182 |
+
className,
|
| 183 |
+
...props
|
| 184 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
| 185 |
+
return (
|
| 186 |
+
<DropdownMenuPrimitive.Separator
|
| 187 |
+
data-slot="dropdown-menu-separator"
|
| 188 |
+
className={cn("bg-border/50 -mx-1 my-1 h-px", className)}
|
| 189 |
+
{...props}
|
| 190 |
+
/>
|
| 191 |
+
)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function DropdownMenuShortcut({
|
| 195 |
+
className,
|
| 196 |
+
...props
|
| 197 |
+
}: React.ComponentProps<"span">) {
|
| 198 |
+
return (
|
| 199 |
+
<span
|
| 200 |
+
data-slot="dropdown-menu-shortcut"
|
| 201 |
+
className={cn(
|
| 202 |
+
"text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
|
| 203 |
+
className
|
| 204 |
+
)}
|
| 205 |
+
{...props}
|
| 206 |
+
/>
|
| 207 |
+
)
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function DropdownMenuSub({
|
| 211 |
+
...props
|
| 212 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
| 213 |
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function DropdownMenuSubTrigger({
|
| 217 |
+
className,
|
| 218 |
+
inset,
|
| 219 |
+
children,
|
| 220 |
+
...props
|
| 221 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
| 222 |
+
inset?: boolean
|
| 223 |
+
}) {
|
| 224 |
+
return (
|
| 225 |
+
<DropdownMenuPrimitive.SubTrigger
|
| 226 |
+
data-slot="dropdown-menu-sub-trigger"
|
| 227 |
+
data-inset={inset}
|
| 228 |
+
className={cn(
|
| 229 |
+
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm outline-hidden select-none data-inset:pl-9.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 230 |
+
className
|
| 231 |
+
)}
|
| 232 |
+
{...props}
|
| 233 |
+
>
|
| 234 |
+
{children}
|
| 235 |
+
<ChevronRightIcon className="ml-auto" />
|
| 236 |
+
</DropdownMenuPrimitive.SubTrigger>
|
| 237 |
+
)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function DropdownMenuSubContent({
|
| 241 |
+
className,
|
| 242 |
+
...props
|
| 243 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
| 244 |
+
return (
|
| 245 |
+
<DropdownMenuPrimitive.SubContent
|
| 246 |
+
data-slot="dropdown-menu-sub-content"
|
| 247 |
+
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-2xl p-1 shadow-2xl ring-1 duration-100", className )}
|
| 248 |
+
{...props}
|
| 249 |
+
/>
|
| 250 |
+
)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
export {
|
| 254 |
+
DropdownMenu,
|
| 255 |
+
DropdownMenuPortal,
|
| 256 |
+
DropdownMenuTrigger,
|
| 257 |
+
DropdownMenuContent,
|
| 258 |
+
DropdownMenuGroup,
|
| 259 |
+
DropdownMenuLabel,
|
| 260 |
+
DropdownMenuItem,
|
| 261 |
+
DropdownMenuCheckboxItem,
|
| 262 |
+
DropdownMenuRadioGroup,
|
| 263 |
+
DropdownMenuRadioItem,
|
| 264 |
+
DropdownMenuSeparator,
|
| 265 |
+
DropdownMenuShortcut,
|
| 266 |
+
DropdownMenuSub,
|
| 267 |
+
DropdownMenuSubTrigger,
|
| 268 |
+
DropdownMenuSubContent,
|
| 269 |
+
}
|
web/src/components/ui/hover-card.tsx
CHANGED
|
@@ -1,44 +1,44 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function HoverCard({
|
| 9 |
-
...props
|
| 10 |
-
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
| 11 |
-
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
function HoverCardTrigger({
|
| 15 |
-
...props
|
| 16 |
-
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
| 17 |
-
return (
|
| 18 |
-
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
| 19 |
-
)
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function HoverCardContent({
|
| 23 |
-
className,
|
| 24 |
-
align = "center",
|
| 25 |
-
sideOffset = 4,
|
| 26 |
-
...props
|
| 27 |
-
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
| 28 |
-
return (
|
| 29 |
-
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
| 30 |
-
<HoverCardPrimitive.Content
|
| 31 |
-
data-slot="hover-card-content"
|
| 32 |
-
align={align}
|
| 33 |
-
sideOffset={sideOffset}
|
| 34 |
-
className={cn(
|
| 35 |
-
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-2xl p-4 text-sm shadow-2xl ring-1 outline-hidden duration-100",
|
| 36 |
-
className
|
| 37 |
-
)}
|
| 38 |
-
{...props}
|
| 39 |
-
/>
|
| 40 |
-
</HoverCardPrimitive.Portal>
|
| 41 |
-
)
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function HoverCard({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
| 11 |
+
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function HoverCardTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
| 17 |
+
return (
|
| 18 |
+
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function HoverCardContent({
|
| 23 |
+
className,
|
| 24 |
+
align = "center",
|
| 25 |
+
sideOffset = 4,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
| 28 |
+
return (
|
| 29 |
+
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
| 30 |
+
<HoverCardPrimitive.Content
|
| 31 |
+
data-slot="hover-card-content"
|
| 32 |
+
align={align}
|
| 33 |
+
sideOffset={sideOffset}
|
| 34 |
+
className={cn(
|
| 35 |
+
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 bg-popover text-popover-foreground z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-2xl p-4 text-sm shadow-2xl ring-1 outline-hidden duration-100",
|
| 36 |
+
className
|
| 37 |
+
)}
|
| 38 |
+
{...props}
|
| 39 |
+
/>
|
| 40 |
+
</HoverCardPrimitive.Portal>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
web/src/components/ui/input.tsx
CHANGED
|
@@ -1,19 +1,19 @@
|
|
| 1 |
-
import * as React from "react"
|
| 2 |
-
|
| 3 |
-
import { cn } from "@/lib/utils"
|
| 4 |
-
|
| 5 |
-
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
-
return (
|
| 7 |
-
<input
|
| 8 |
-
type={type}
|
| 9 |
-
data-slot="input"
|
| 10 |
-
className={cn(
|
| 11 |
-
"bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 file:text-foreground placeholder:text-muted-foreground h-9 w-full min-w-0 rounded-4xl border px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] md:text-sm",
|
| 12 |
-
className
|
| 13 |
-
)}
|
| 14 |
-
{...props}
|
| 15 |
-
/>
|
| 16 |
-
)
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
export { Input }
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
data-slot="input"
|
| 10 |
+
className={cn(
|
| 11 |
+
"bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 file:text-foreground placeholder:text-muted-foreground h-9 w-full min-w-0 rounded-4xl border px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] md:text-sm",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
{...props}
|
| 15 |
+
/>
|
| 16 |
+
)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export { Input }
|
web/src/components/ui/label.tsx
CHANGED
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Label as LabelPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Label({
|
| 9 |
-
className,
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 12 |
-
return (
|
| 13 |
-
<LabelPrimitive.Root
|
| 14 |
-
data-slot="label"
|
| 15 |
-
className={cn(
|
| 16 |
-
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
| 17 |
-
className
|
| 18 |
-
)}
|
| 19 |
-
{...props}
|
| 20 |
-
/>
|
| 21 |
-
)
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
export { Label }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Label as LabelPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Label({
|
| 9 |
+
className,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<LabelPrimitive.Root
|
| 14 |
+
data-slot="label"
|
| 15 |
+
className={cn(
|
| 16 |
+
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export { Label }
|
web/src/components/ui/progress.tsx
CHANGED
|
@@ -1,31 +1,31 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Progress as ProgressPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Progress({
|
| 9 |
-
className,
|
| 10 |
-
value,
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
| 13 |
-
return (
|
| 14 |
-
<ProgressPrimitive.Root
|
| 15 |
-
data-slot="progress"
|
| 16 |
-
className={cn(
|
| 17 |
-
"bg-muted relative flex h-3 w-full items-center overflow-x-hidden rounded-4xl",
|
| 18 |
-
className
|
| 19 |
-
)}
|
| 20 |
-
{...props}
|
| 21 |
-
>
|
| 22 |
-
<ProgressPrimitive.Indicator
|
| 23 |
-
data-slot="progress-indicator"
|
| 24 |
-
className="bg-primary size-full flex-1 transition-all"
|
| 25 |
-
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
| 26 |
-
/>
|
| 27 |
-
</ProgressPrimitive.Root>
|
| 28 |
-
)
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
export { Progress }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Progress as ProgressPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Progress({
|
| 9 |
+
className,
|
| 10 |
+
value,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ProgressPrimitive.Root
|
| 15 |
+
data-slot="progress"
|
| 16 |
+
className={cn(
|
| 17 |
+
"bg-muted relative flex h-3 w-full items-center overflow-x-hidden rounded-4xl",
|
| 18 |
+
className
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
>
|
| 22 |
+
<ProgressPrimitive.Indicator
|
| 23 |
+
data-slot="progress-indicator"
|
| 24 |
+
className="bg-primary size-full flex-1 transition-all"
|
| 25 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
| 26 |
+
/>
|
| 27 |
+
</ProgressPrimitive.Root>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { Progress }
|
web/src/components/ui/scroll-area.tsx
CHANGED
|
@@ -1,55 +1,55 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function ScrollArea({
|
| 9 |
-
className,
|
| 10 |
-
children,
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
| 13 |
-
return (
|
| 14 |
-
<ScrollAreaPrimitive.Root
|
| 15 |
-
data-slot="scroll-area"
|
| 16 |
-
className={cn("relative", className)}
|
| 17 |
-
{...props}
|
| 18 |
-
>
|
| 19 |
-
<ScrollAreaPrimitive.Viewport
|
| 20 |
-
data-slot="scroll-area-viewport"
|
| 21 |
-
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
| 22 |
-
>
|
| 23 |
-
{children}
|
| 24 |
-
</ScrollAreaPrimitive.Viewport>
|
| 25 |
-
<ScrollBar />
|
| 26 |
-
<ScrollAreaPrimitive.Corner />
|
| 27 |
-
</ScrollAreaPrimitive.Root>
|
| 28 |
-
)
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
function ScrollBar({
|
| 32 |
-
className,
|
| 33 |
-
orientation = "vertical",
|
| 34 |
-
...props
|
| 35 |
-
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
| 36 |
-
return (
|
| 37 |
-
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 38 |
-
data-slot="scroll-area-scrollbar"
|
| 39 |
-
data-orientation={orientation}
|
| 40 |
-
orientation={orientation}
|
| 41 |
-
className={cn(
|
| 42 |
-
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
| 43 |
-
className
|
| 44 |
-
)}
|
| 45 |
-
{...props}
|
| 46 |
-
>
|
| 47 |
-
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 48 |
-
data-slot="scroll-area-thumb"
|
| 49 |
-
className="bg-border relative flex-1 rounded-full"
|
| 50 |
-
/>
|
| 51 |
-
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 52 |
-
)
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export { ScrollArea, ScrollBar }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function ScrollArea({
|
| 9 |
+
className,
|
| 10 |
+
children,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<ScrollAreaPrimitive.Root
|
| 15 |
+
data-slot="scroll-area"
|
| 16 |
+
className={cn("relative", className)}
|
| 17 |
+
{...props}
|
| 18 |
+
>
|
| 19 |
+
<ScrollAreaPrimitive.Viewport
|
| 20 |
+
data-slot="scroll-area-viewport"
|
| 21 |
+
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
| 22 |
+
>
|
| 23 |
+
{children}
|
| 24 |
+
</ScrollAreaPrimitive.Viewport>
|
| 25 |
+
<ScrollBar />
|
| 26 |
+
<ScrollAreaPrimitive.Corner />
|
| 27 |
+
</ScrollAreaPrimitive.Root>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ScrollBar({
|
| 32 |
+
className,
|
| 33 |
+
orientation = "vertical",
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
| 36 |
+
return (
|
| 37 |
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
| 38 |
+
data-slot="scroll-area-scrollbar"
|
| 39 |
+
data-orientation={orientation}
|
| 40 |
+
orientation={orientation}
|
| 41 |
+
className={cn(
|
| 42 |
+
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
>
|
| 47 |
+
<ScrollAreaPrimitive.ScrollAreaThumb
|
| 48 |
+
data-slot="scroll-area-thumb"
|
| 49 |
+
className="bg-border relative flex-1 rounded-full"
|
| 50 |
+
/>
|
| 51 |
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
| 52 |
+
)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export { ScrollArea, ScrollBar }
|
web/src/components/ui/select.tsx
CHANGED
|
@@ -1,195 +1,195 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Select as SelectPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
| 8 |
-
|
| 9 |
-
function Select({
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
| 12 |
-
return <SelectPrimitive.Root data-slot="select" {...props} />
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
function SelectGroup({
|
| 16 |
-
className,
|
| 17 |
-
...props
|
| 18 |
-
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
| 19 |
-
return (
|
| 20 |
-
<SelectPrimitive.Group
|
| 21 |
-
data-slot="select-group"
|
| 22 |
-
className={cn("scroll-my-1 p-1", className)}
|
| 23 |
-
{...props}
|
| 24 |
-
/>
|
| 25 |
-
)
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
function SelectValue({
|
| 29 |
-
...props
|
| 30 |
-
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
| 31 |
-
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
function SelectTrigger({
|
| 35 |
-
className,
|
| 36 |
-
size = "default",
|
| 37 |
-
children,
|
| 38 |
-
...props
|
| 39 |
-
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
| 40 |
-
size?: "sm" | "default"
|
| 41 |
-
}) {
|
| 42 |
-
return (
|
| 43 |
-
<SelectPrimitive.Trigger
|
| 44 |
-
data-slot="select-trigger"
|
| 45 |
-
data-size={size}
|
| 46 |
-
className={cn(
|
| 47 |
-
"border-input data-placeholder:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 flex w-fit items-center justify-between gap-1.5 rounded-4xl border px-3 py-2 text-sm whitespace-nowrap transition-colors outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 48 |
-
className
|
| 49 |
-
)}
|
| 50 |
-
{...props}
|
| 51 |
-
>
|
| 52 |
-
{children}
|
| 53 |
-
<SelectPrimitive.Icon asChild>
|
| 54 |
-
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" />
|
| 55 |
-
</SelectPrimitive.Icon>
|
| 56 |
-
</SelectPrimitive.Trigger>
|
| 57 |
-
)
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
function SelectContent({
|
| 61 |
-
className,
|
| 62 |
-
children,
|
| 63 |
-
position = "item-aligned",
|
| 64 |
-
align = "center",
|
| 65 |
-
...props
|
| 66 |
-
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
| 67 |
-
return (
|
| 68 |
-
<SelectPrimitive.Portal>
|
| 69 |
-
<SelectPrimitive.Content
|
| 70 |
-
data-slot="select-content"
|
| 71 |
-
data-align-trigger={position === "item-aligned"}
|
| 72 |
-
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl shadow-2xl ring-1 duration-100 data-[align-trigger=true]:animate-none", 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 )}
|
| 73 |
-
position={position}
|
| 74 |
-
align={align}
|
| 75 |
-
{...props}
|
| 76 |
-
>
|
| 77 |
-
<SelectScrollUpButton />
|
| 78 |
-
<SelectPrimitive.Viewport
|
| 79 |
-
data-position={position}
|
| 80 |
-
className={cn(
|
| 81 |
-
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
| 82 |
-
position === "popper" && ""
|
| 83 |
-
)}
|
| 84 |
-
>
|
| 85 |
-
{children}
|
| 86 |
-
</SelectPrimitive.Viewport>
|
| 87 |
-
<SelectScrollDownButton />
|
| 88 |
-
</SelectPrimitive.Content>
|
| 89 |
-
</SelectPrimitive.Portal>
|
| 90 |
-
)
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
function SelectLabel({
|
| 94 |
-
className,
|
| 95 |
-
...props
|
| 96 |
-
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
| 97 |
-
return (
|
| 98 |
-
<SelectPrimitive.Label
|
| 99 |
-
data-slot="select-label"
|
| 100 |
-
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
| 101 |
-
{...props}
|
| 102 |
-
/>
|
| 103 |
-
)
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
function SelectItem({
|
| 107 |
-
className,
|
| 108 |
-
children,
|
| 109 |
-
...props
|
| 110 |
-
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
| 111 |
-
return (
|
| 112 |
-
<SelectPrimitive.Item
|
| 113 |
-
data-slot="select-item"
|
| 114 |
-
className={cn(
|
| 115 |
-
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
| 116 |
-
className
|
| 117 |
-
)}
|
| 118 |
-
{...props}
|
| 119 |
-
>
|
| 120 |
-
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
| 121 |
-
<SelectPrimitive.ItemIndicator>
|
| 122 |
-
<CheckIcon className="pointer-events-none" />
|
| 123 |
-
</SelectPrimitive.ItemIndicator>
|
| 124 |
-
</span>
|
| 125 |
-
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 126 |
-
</SelectPrimitive.Item>
|
| 127 |
-
)
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
function SelectSeparator({
|
| 131 |
-
className,
|
| 132 |
-
...props
|
| 133 |
-
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
| 134 |
-
return (
|
| 135 |
-
<SelectPrimitive.Separator
|
| 136 |
-
data-slot="select-separator"
|
| 137 |
-
className={cn(
|
| 138 |
-
"bg-border/50 pointer-events-none -mx-1 my-1 h-px",
|
| 139 |
-
className
|
| 140 |
-
)}
|
| 141 |
-
{...props}
|
| 142 |
-
/>
|
| 143 |
-
)
|
| 144 |
-
}
|
| 145 |
-
|
| 146 |
-
function SelectScrollUpButton({
|
| 147 |
-
className,
|
| 148 |
-
...props
|
| 149 |
-
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
| 150 |
-
return (
|
| 151 |
-
<SelectPrimitive.ScrollUpButton
|
| 152 |
-
data-slot="select-scroll-up-button"
|
| 153 |
-
className={cn(
|
| 154 |
-
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
| 155 |
-
className
|
| 156 |
-
)}
|
| 157 |
-
{...props}
|
| 158 |
-
>
|
| 159 |
-
<ChevronUpIcon
|
| 160 |
-
/>
|
| 161 |
-
</SelectPrimitive.ScrollUpButton>
|
| 162 |
-
)
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
function SelectScrollDownButton({
|
| 166 |
-
className,
|
| 167 |
-
...props
|
| 168 |
-
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
| 169 |
-
return (
|
| 170 |
-
<SelectPrimitive.ScrollDownButton
|
| 171 |
-
data-slot="select-scroll-down-button"
|
| 172 |
-
className={cn(
|
| 173 |
-
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
| 174 |
-
className
|
| 175 |
-
)}
|
| 176 |
-
{...props}
|
| 177 |
-
>
|
| 178 |
-
<ChevronDownIcon
|
| 179 |
-
/>
|
| 180 |
-
</SelectPrimitive.ScrollDownButton>
|
| 181 |
-
)
|
| 182 |
-
}
|
| 183 |
-
|
| 184 |
-
export {
|
| 185 |
-
Select,
|
| 186 |
-
SelectContent,
|
| 187 |
-
SelectGroup,
|
| 188 |
-
SelectItem,
|
| 189 |
-
SelectLabel,
|
| 190 |
-
SelectScrollDownButton,
|
| 191 |
-
SelectScrollUpButton,
|
| 192 |
-
SelectSeparator,
|
| 193 |
-
SelectTrigger,
|
| 194 |
-
SelectValue,
|
| 195 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Select as SelectPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
| 8 |
+
|
| 9 |
+
function Select({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
| 12 |
+
return <SelectPrimitive.Root data-slot="select" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function SelectGroup({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
| 19 |
+
return (
|
| 20 |
+
<SelectPrimitive.Group
|
| 21 |
+
data-slot="select-group"
|
| 22 |
+
className={cn("scroll-my-1 p-1", className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function SelectValue({
|
| 29 |
+
...props
|
| 30 |
+
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
| 31 |
+
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function SelectTrigger({
|
| 35 |
+
className,
|
| 36 |
+
size = "default",
|
| 37 |
+
children,
|
| 38 |
+
...props
|
| 39 |
+
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
| 40 |
+
size?: "sm" | "default"
|
| 41 |
+
}) {
|
| 42 |
+
return (
|
| 43 |
+
<SelectPrimitive.Trigger
|
| 44 |
+
data-slot="select-trigger"
|
| 45 |
+
data-size={size}
|
| 46 |
+
className={cn(
|
| 47 |
+
"border-input data-placeholder:text-muted-foreground bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 flex w-fit items-center justify-between gap-1.5 rounded-4xl border px-3 py-2 text-sm whitespace-nowrap transition-colors outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 48 |
+
className
|
| 49 |
+
)}
|
| 50 |
+
{...props}
|
| 51 |
+
>
|
| 52 |
+
{children}
|
| 53 |
+
<SelectPrimitive.Icon asChild>
|
| 54 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4" />
|
| 55 |
+
</SelectPrimitive.Icon>
|
| 56 |
+
</SelectPrimitive.Trigger>
|
| 57 |
+
)
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function SelectContent({
|
| 61 |
+
className,
|
| 62 |
+
children,
|
| 63 |
+
position = "item-aligned",
|
| 64 |
+
align = "center",
|
| 65 |
+
...props
|
| 66 |
+
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
| 67 |
+
return (
|
| 68 |
+
<SelectPrimitive.Portal>
|
| 69 |
+
<SelectPrimitive.Content
|
| 70 |
+
data-slot="select-content"
|
| 71 |
+
data-align-trigger={position === "item-aligned"}
|
| 72 |
+
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-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 ring-foreground/5 relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl shadow-2xl ring-1 duration-100 data-[align-trigger=true]:animate-none", 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 )}
|
| 73 |
+
position={position}
|
| 74 |
+
align={align}
|
| 75 |
+
{...props}
|
| 76 |
+
>
|
| 77 |
+
<SelectScrollUpButton />
|
| 78 |
+
<SelectPrimitive.Viewport
|
| 79 |
+
data-position={position}
|
| 80 |
+
className={cn(
|
| 81 |
+
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
| 82 |
+
position === "popper" && ""
|
| 83 |
+
)}
|
| 84 |
+
>
|
| 85 |
+
{children}
|
| 86 |
+
</SelectPrimitive.Viewport>
|
| 87 |
+
<SelectScrollDownButton />
|
| 88 |
+
</SelectPrimitive.Content>
|
| 89 |
+
</SelectPrimitive.Portal>
|
| 90 |
+
)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function SelectLabel({
|
| 94 |
+
className,
|
| 95 |
+
...props
|
| 96 |
+
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
| 97 |
+
return (
|
| 98 |
+
<SelectPrimitive.Label
|
| 99 |
+
data-slot="select-label"
|
| 100 |
+
className={cn("text-muted-foreground px-3 py-2.5 text-xs", className)}
|
| 101 |
+
{...props}
|
| 102 |
+
/>
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function SelectItem({
|
| 107 |
+
className,
|
| 108 |
+
children,
|
| 109 |
+
...props
|
| 110 |
+
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
| 111 |
+
return (
|
| 112 |
+
<SelectPrimitive.Item
|
| 113 |
+
data-slot="select-item"
|
| 114 |
+
className={cn(
|
| 115 |
+
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
| 116 |
+
className
|
| 117 |
+
)}
|
| 118 |
+
{...props}
|
| 119 |
+
>
|
| 120 |
+
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
| 121 |
+
<SelectPrimitive.ItemIndicator>
|
| 122 |
+
<CheckIcon className="pointer-events-none" />
|
| 123 |
+
</SelectPrimitive.ItemIndicator>
|
| 124 |
+
</span>
|
| 125 |
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
| 126 |
+
</SelectPrimitive.Item>
|
| 127 |
+
)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function SelectSeparator({
|
| 131 |
+
className,
|
| 132 |
+
...props
|
| 133 |
+
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
| 134 |
+
return (
|
| 135 |
+
<SelectPrimitive.Separator
|
| 136 |
+
data-slot="select-separator"
|
| 137 |
+
className={cn(
|
| 138 |
+
"bg-border/50 pointer-events-none -mx-1 my-1 h-px",
|
| 139 |
+
className
|
| 140 |
+
)}
|
| 141 |
+
{...props}
|
| 142 |
+
/>
|
| 143 |
+
)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function SelectScrollUpButton({
|
| 147 |
+
className,
|
| 148 |
+
...props
|
| 149 |
+
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
| 150 |
+
return (
|
| 151 |
+
<SelectPrimitive.ScrollUpButton
|
| 152 |
+
data-slot="select-scroll-up-button"
|
| 153 |
+
className={cn(
|
| 154 |
+
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
| 155 |
+
className
|
| 156 |
+
)}
|
| 157 |
+
{...props}
|
| 158 |
+
>
|
| 159 |
+
<ChevronUpIcon
|
| 160 |
+
/>
|
| 161 |
+
</SelectPrimitive.ScrollUpButton>
|
| 162 |
+
)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function SelectScrollDownButton({
|
| 166 |
+
className,
|
| 167 |
+
...props
|
| 168 |
+
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
| 169 |
+
return (
|
| 170 |
+
<SelectPrimitive.ScrollDownButton
|
| 171 |
+
data-slot="select-scroll-down-button"
|
| 172 |
+
className={cn(
|
| 173 |
+
"bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4",
|
| 174 |
+
className
|
| 175 |
+
)}
|
| 176 |
+
{...props}
|
| 177 |
+
>
|
| 178 |
+
<ChevronDownIcon
|
| 179 |
+
/>
|
| 180 |
+
</SelectPrimitive.ScrollDownButton>
|
| 181 |
+
)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
export {
|
| 185 |
+
Select,
|
| 186 |
+
SelectContent,
|
| 187 |
+
SelectGroup,
|
| 188 |
+
SelectItem,
|
| 189 |
+
SelectLabel,
|
| 190 |
+
SelectScrollDownButton,
|
| 191 |
+
SelectScrollUpButton,
|
| 192 |
+
SelectSeparator,
|
| 193 |
+
SelectTrigger,
|
| 194 |
+
SelectValue,
|
| 195 |
+
}
|
web/src/components/ui/separator.tsx
CHANGED
|
@@ -1,28 +1,28 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Separator as SeparatorPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Separator({
|
| 9 |
-
className,
|
| 10 |
-
orientation = "horizontal",
|
| 11 |
-
decorative = true,
|
| 12 |
-
...props
|
| 13 |
-
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
| 14 |
-
return (
|
| 15 |
-
<SeparatorPrimitive.Root
|
| 16 |
-
data-slot="separator"
|
| 17 |
-
decorative={decorative}
|
| 18 |
-
orientation={orientation}
|
| 19 |
-
className={cn(
|
| 20 |
-
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
| 21 |
-
className
|
| 22 |
-
)}
|
| 23 |
-
{...props}
|
| 24 |
-
/>
|
| 25 |
-
)
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
export { Separator }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Separator as SeparatorPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Separator({
|
| 9 |
+
className,
|
| 10 |
+
orientation = "horizontal",
|
| 11 |
+
decorative = true,
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
| 14 |
+
return (
|
| 15 |
+
<SeparatorPrimitive.Root
|
| 16 |
+
data-slot="separator"
|
| 17 |
+
decorative={decorative}
|
| 18 |
+
orientation={orientation}
|
| 19 |
+
className={cn(
|
| 20 |
+
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export { Separator }
|
web/src/components/ui/sheet.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Dialog as SheetPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import { XIcon } from "lucide-react"
|
| 9 |
+
|
| 10 |
+
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
| 11 |
+
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function SheetTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
| 17 |
+
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function SheetClose({
|
| 21 |
+
...props
|
| 22 |
+
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
| 23 |
+
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function SheetPortal({
|
| 27 |
+
...props
|
| 28 |
+
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
| 29 |
+
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function SheetOverlay({
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
| 36 |
+
return (
|
| 37 |
+
<SheetPrimitive.Overlay
|
| 38 |
+
data-slot="sheet-overlay"
|
| 39 |
+
className={cn(
|
| 40 |
+
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
| 41 |
+
className
|
| 42 |
+
)}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function SheetContent({
|
| 49 |
+
className,
|
| 50 |
+
children,
|
| 51 |
+
side = "right",
|
| 52 |
+
showCloseButton = true,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
| 55 |
+
side?: "top" | "right" | "bottom" | "left"
|
| 56 |
+
showCloseButton?: boolean
|
| 57 |
+
}) {
|
| 58 |
+
return (
|
| 59 |
+
<SheetPortal>
|
| 60 |
+
<SheetOverlay />
|
| 61 |
+
<SheetPrimitive.Content
|
| 62 |
+
data-slot="sheet-content"
|
| 63 |
+
data-side={side}
|
| 64 |
+
className={cn(
|
| 65 |
+
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
| 66 |
+
className
|
| 67 |
+
)}
|
| 68 |
+
{...props}
|
| 69 |
+
>
|
| 70 |
+
{children}
|
| 71 |
+
{showCloseButton && (
|
| 72 |
+
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
| 73 |
+
<Button
|
| 74 |
+
variant="ghost"
|
| 75 |
+
className="absolute top-4 right-4"
|
| 76 |
+
size="icon-sm"
|
| 77 |
+
>
|
| 78 |
+
<XIcon
|
| 79 |
+
/>
|
| 80 |
+
<span className="sr-only">Close</span>
|
| 81 |
+
</Button>
|
| 82 |
+
</SheetPrimitive.Close>
|
| 83 |
+
)}
|
| 84 |
+
</SheetPrimitive.Content>
|
| 85 |
+
</SheetPortal>
|
| 86 |
+
)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 90 |
+
return (
|
| 91 |
+
<div
|
| 92 |
+
data-slot="sheet-header"
|
| 93 |
+
className={cn("flex flex-col gap-1.5 p-6", className)}
|
| 94 |
+
{...props}
|
| 95 |
+
/>
|
| 96 |
+
)
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 100 |
+
return (
|
| 101 |
+
<div
|
| 102 |
+
data-slot="sheet-footer"
|
| 103 |
+
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
|
| 104 |
+
{...props}
|
| 105 |
+
/>
|
| 106 |
+
)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function SheetTitle({
|
| 110 |
+
className,
|
| 111 |
+
...props
|
| 112 |
+
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
| 113 |
+
return (
|
| 114 |
+
<SheetPrimitive.Title
|
| 115 |
+
data-slot="sheet-title"
|
| 116 |
+
className={cn("text-foreground text-base font-medium", className)}
|
| 117 |
+
{...props}
|
| 118 |
+
/>
|
| 119 |
+
)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function SheetDescription({
|
| 123 |
+
className,
|
| 124 |
+
...props
|
| 125 |
+
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
| 126 |
+
return (
|
| 127 |
+
<SheetPrimitive.Description
|
| 128 |
+
data-slot="sheet-description"
|
| 129 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 130 |
+
{...props}
|
| 131 |
+
/>
|
| 132 |
+
)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export {
|
| 136 |
+
Sheet,
|
| 137 |
+
SheetTrigger,
|
| 138 |
+
SheetClose,
|
| 139 |
+
SheetContent,
|
| 140 |
+
SheetHeader,
|
| 141 |
+
SheetFooter,
|
| 142 |
+
SheetTitle,
|
| 143 |
+
SheetDescription,
|
| 144 |
+
}
|
web/src/components/ui/sidebar.tsx
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 5 |
+
import { Slot } from "radix-ui"
|
| 6 |
+
|
| 7 |
+
import { useIsMobile } from "@/hooks/use-mobile"
|
| 8 |
+
import { cn } from "@/lib/utils"
|
| 9 |
+
import { Button } from "@/components/ui/button"
|
| 10 |
+
import { Input } from "@/components/ui/input"
|
| 11 |
+
import { Separator } from "@/components/ui/separator"
|
| 12 |
+
import {
|
| 13 |
+
Sheet,
|
| 14 |
+
SheetContent,
|
| 15 |
+
SheetDescription,
|
| 16 |
+
SheetHeader,
|
| 17 |
+
SheetTitle,
|
| 18 |
+
} from "@/components/ui/sheet"
|
| 19 |
+
import { Skeleton } from "@/components/ui/skeleton"
|
| 20 |
+
import {
|
| 21 |
+
Tooltip,
|
| 22 |
+
TooltipContent,
|
| 23 |
+
TooltipTrigger,
|
| 24 |
+
} from "@/components/ui/tooltip"
|
| 25 |
+
import { PanelLeftIcon } from "lucide-react"
|
| 26 |
+
|
| 27 |
+
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
| 28 |
+
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
| 29 |
+
const SIDEBAR_WIDTH = "16rem"
|
| 30 |
+
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
| 31 |
+
const SIDEBAR_WIDTH_ICON = "3rem"
|
| 32 |
+
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
| 33 |
+
|
| 34 |
+
type SidebarContextProps = {
|
| 35 |
+
state: "expanded" | "collapsed"
|
| 36 |
+
open: boolean
|
| 37 |
+
setOpen: (open: boolean) => void
|
| 38 |
+
openMobile: boolean
|
| 39 |
+
setOpenMobile: (open: boolean) => void
|
| 40 |
+
isMobile: boolean
|
| 41 |
+
toggleSidebar: () => void
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
| 45 |
+
|
| 46 |
+
function useSidebar() {
|
| 47 |
+
const context = React.useContext(SidebarContext)
|
| 48 |
+
if (!context) {
|
| 49 |
+
throw new Error("useSidebar must be used within a SidebarProvider.")
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return context
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function SidebarProvider({
|
| 56 |
+
defaultOpen = true,
|
| 57 |
+
open: openProp,
|
| 58 |
+
onOpenChange: setOpenProp,
|
| 59 |
+
className,
|
| 60 |
+
style,
|
| 61 |
+
children,
|
| 62 |
+
...props
|
| 63 |
+
}: React.ComponentProps<"div"> & {
|
| 64 |
+
defaultOpen?: boolean
|
| 65 |
+
open?: boolean
|
| 66 |
+
onOpenChange?: (open: boolean) => void
|
| 67 |
+
}) {
|
| 68 |
+
const isMobile = useIsMobile()
|
| 69 |
+
const [openMobile, setOpenMobile] = React.useState(false)
|
| 70 |
+
|
| 71 |
+
// This is the internal state of the sidebar.
|
| 72 |
+
// We use openProp and setOpenProp for control from outside the component.
|
| 73 |
+
const [_open, _setOpen] = React.useState(defaultOpen)
|
| 74 |
+
const open = openProp ?? _open
|
| 75 |
+
const setOpen = React.useCallback(
|
| 76 |
+
(value: boolean | ((value: boolean) => boolean)) => {
|
| 77 |
+
const openState = typeof value === "function" ? value(open) : value
|
| 78 |
+
if (setOpenProp) {
|
| 79 |
+
setOpenProp(openState)
|
| 80 |
+
} else {
|
| 81 |
+
_setOpen(openState)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// This sets the cookie to keep the sidebar state.
|
| 85 |
+
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
| 86 |
+
},
|
| 87 |
+
[setOpenProp, open]
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
// Helper to toggle the sidebar.
|
| 91 |
+
const toggleSidebar = React.useCallback(() => {
|
| 92 |
+
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
| 93 |
+
}, [isMobile, setOpen, setOpenMobile])
|
| 94 |
+
|
| 95 |
+
// Adds a keyboard shortcut to toggle the sidebar.
|
| 96 |
+
React.useEffect(() => {
|
| 97 |
+
const handleKeyDown = (event: KeyboardEvent) => {
|
| 98 |
+
if (
|
| 99 |
+
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
| 100 |
+
(event.metaKey || event.ctrlKey)
|
| 101 |
+
) {
|
| 102 |
+
event.preventDefault()
|
| 103 |
+
toggleSidebar()
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
window.addEventListener("keydown", handleKeyDown)
|
| 108 |
+
return () => window.removeEventListener("keydown", handleKeyDown)
|
| 109 |
+
}, [toggleSidebar])
|
| 110 |
+
|
| 111 |
+
// We add a state so that we can do data-state="expanded" or "collapsed".
|
| 112 |
+
// This makes it easier to style the sidebar with Tailwind classes.
|
| 113 |
+
const state = open ? "expanded" : "collapsed"
|
| 114 |
+
|
| 115 |
+
const contextValue = React.useMemo<SidebarContextProps>(
|
| 116 |
+
() => ({
|
| 117 |
+
state,
|
| 118 |
+
open,
|
| 119 |
+
setOpen,
|
| 120 |
+
isMobile,
|
| 121 |
+
openMobile,
|
| 122 |
+
setOpenMobile,
|
| 123 |
+
toggleSidebar,
|
| 124 |
+
}),
|
| 125 |
+
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
<SidebarContext.Provider value={contextValue}>
|
| 130 |
+
<div
|
| 131 |
+
data-slot="sidebar-wrapper"
|
| 132 |
+
style={
|
| 133 |
+
{
|
| 134 |
+
"--sidebar-width": SIDEBAR_WIDTH,
|
| 135 |
+
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
| 136 |
+
...style,
|
| 137 |
+
} as React.CSSProperties
|
| 138 |
+
}
|
| 139 |
+
className={cn(
|
| 140 |
+
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
| 141 |
+
className
|
| 142 |
+
)}
|
| 143 |
+
{...props}
|
| 144 |
+
>
|
| 145 |
+
{children}
|
| 146 |
+
</div>
|
| 147 |
+
</SidebarContext.Provider>
|
| 148 |
+
)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function Sidebar({
|
| 152 |
+
side = "left",
|
| 153 |
+
variant = "sidebar",
|
| 154 |
+
collapsible = "offcanvas",
|
| 155 |
+
className,
|
| 156 |
+
children,
|
| 157 |
+
dir,
|
| 158 |
+
...props
|
| 159 |
+
}: React.ComponentProps<"div"> & {
|
| 160 |
+
side?: "left" | "right"
|
| 161 |
+
variant?: "sidebar" | "floating" | "inset"
|
| 162 |
+
collapsible?: "offcanvas" | "icon" | "none"
|
| 163 |
+
}) {
|
| 164 |
+
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
| 165 |
+
|
| 166 |
+
if (collapsible === "none") {
|
| 167 |
+
return (
|
| 168 |
+
<div
|
| 169 |
+
data-slot="sidebar"
|
| 170 |
+
className={cn(
|
| 171 |
+
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
| 172 |
+
className
|
| 173 |
+
)}
|
| 174 |
+
{...props}
|
| 175 |
+
>
|
| 176 |
+
{children}
|
| 177 |
+
</div>
|
| 178 |
+
)
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (isMobile) {
|
| 182 |
+
return (
|
| 183 |
+
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
| 184 |
+
<SheetContent
|
| 185 |
+
dir={dir}
|
| 186 |
+
data-sidebar="sidebar"
|
| 187 |
+
data-slot="sidebar"
|
| 188 |
+
data-mobile="true"
|
| 189 |
+
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
| 190 |
+
style={
|
| 191 |
+
{
|
| 192 |
+
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
| 193 |
+
} as React.CSSProperties
|
| 194 |
+
}
|
| 195 |
+
side={side}
|
| 196 |
+
>
|
| 197 |
+
<SheetHeader className="sr-only">
|
| 198 |
+
<SheetTitle>Sidebar</SheetTitle>
|
| 199 |
+
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
| 200 |
+
</SheetHeader>
|
| 201 |
+
<div className="flex h-full w-full flex-col">{children}</div>
|
| 202 |
+
</SheetContent>
|
| 203 |
+
</Sheet>
|
| 204 |
+
)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
return (
|
| 208 |
+
<div
|
| 209 |
+
className="group peer text-sidebar-foreground hidden md:block"
|
| 210 |
+
data-state={state}
|
| 211 |
+
data-collapsible={state === "collapsed" ? collapsible : ""}
|
| 212 |
+
data-variant={variant}
|
| 213 |
+
data-side={side}
|
| 214 |
+
data-slot="sidebar"
|
| 215 |
+
>
|
| 216 |
+
{/* This is what handles the sidebar gap on desktop */}
|
| 217 |
+
<div
|
| 218 |
+
data-slot="sidebar-gap"
|
| 219 |
+
className={cn(
|
| 220 |
+
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
| 221 |
+
"group-data-[collapsible=offcanvas]:w-0",
|
| 222 |
+
"group-data-[side=right]:rotate-180",
|
| 223 |
+
variant === "floating" || variant === "inset"
|
| 224 |
+
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
| 225 |
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
| 226 |
+
)}
|
| 227 |
+
/>
|
| 228 |
+
<div
|
| 229 |
+
data-slot="sidebar-container"
|
| 230 |
+
data-side={side}
|
| 231 |
+
className={cn(
|
| 232 |
+
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
| 233 |
+
// Adjust the padding for floating and inset variants.
|
| 234 |
+
variant === "floating" || variant === "inset"
|
| 235 |
+
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
| 236 |
+
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
| 237 |
+
className
|
| 238 |
+
)}
|
| 239 |
+
{...props}
|
| 240 |
+
>
|
| 241 |
+
<div
|
| 242 |
+
data-sidebar="sidebar"
|
| 243 |
+
data-slot="sidebar-inner"
|
| 244 |
+
className="bg-sidebar group-data-[variant=floating]:ring-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1"
|
| 245 |
+
>
|
| 246 |
+
{children}
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
function SidebarTrigger({
|
| 254 |
+
className,
|
| 255 |
+
onClick,
|
| 256 |
+
...props
|
| 257 |
+
}: React.ComponentProps<typeof Button>) {
|
| 258 |
+
const { toggleSidebar } = useSidebar()
|
| 259 |
+
|
| 260 |
+
return (
|
| 261 |
+
<Button
|
| 262 |
+
data-sidebar="trigger"
|
| 263 |
+
data-slot="sidebar-trigger"
|
| 264 |
+
variant="ghost"
|
| 265 |
+
size="icon-sm"
|
| 266 |
+
className={cn(className)}
|
| 267 |
+
onClick={(event) => {
|
| 268 |
+
onClick?.(event)
|
| 269 |
+
toggleSidebar()
|
| 270 |
+
}}
|
| 271 |
+
{...props}
|
| 272 |
+
>
|
| 273 |
+
<PanelLeftIcon />
|
| 274 |
+
<span className="sr-only">Toggle Sidebar</span>
|
| 275 |
+
</Button>
|
| 276 |
+
)
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
| 280 |
+
const { toggleSidebar } = useSidebar()
|
| 281 |
+
|
| 282 |
+
return (
|
| 283 |
+
<button
|
| 284 |
+
data-sidebar="rail"
|
| 285 |
+
data-slot="sidebar-rail"
|
| 286 |
+
aria-label="Toggle Sidebar"
|
| 287 |
+
tabIndex={-1}
|
| 288 |
+
onClick={toggleSidebar}
|
| 289 |
+
title="Toggle Sidebar"
|
| 290 |
+
className={cn(
|
| 291 |
+
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
| 292 |
+
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
| 293 |
+
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
| 294 |
+
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
| 295 |
+
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
| 296 |
+
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
| 297 |
+
className
|
| 298 |
+
)}
|
| 299 |
+
{...props}
|
| 300 |
+
/>
|
| 301 |
+
)
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
| 305 |
+
return (
|
| 306 |
+
<main
|
| 307 |
+
data-slot="sidebar-inset"
|
| 308 |
+
className={cn(
|
| 309 |
+
"bg-background relative flex w-full flex-1 flex-col md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
| 310 |
+
className
|
| 311 |
+
)}
|
| 312 |
+
{...props}
|
| 313 |
+
/>
|
| 314 |
+
)
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function SidebarInput({
|
| 318 |
+
className,
|
| 319 |
+
...props
|
| 320 |
+
}: React.ComponentProps<typeof Input>) {
|
| 321 |
+
return (
|
| 322 |
+
<Input
|
| 323 |
+
data-slot="sidebar-input"
|
| 324 |
+
data-sidebar="input"
|
| 325 |
+
className={cn("bg-background h-8 w-full shadow-none", className)}
|
| 326 |
+
{...props}
|
| 327 |
+
/>
|
| 328 |
+
)
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 332 |
+
return (
|
| 333 |
+
<div
|
| 334 |
+
data-slot="sidebar-header"
|
| 335 |
+
data-sidebar="header"
|
| 336 |
+
className={cn(
|
| 337 |
+
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
|
| 338 |
+
className
|
| 339 |
+
)}
|
| 340 |
+
{...props}
|
| 341 |
+
/>
|
| 342 |
+
)
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 346 |
+
return (
|
| 347 |
+
<div
|
| 348 |
+
data-slot="sidebar-footer"
|
| 349 |
+
data-sidebar="footer"
|
| 350 |
+
className={cn("flex flex-col gap-2 p-2", className)}
|
| 351 |
+
{...props}
|
| 352 |
+
/>
|
| 353 |
+
)
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
function SidebarSeparator({
|
| 357 |
+
className,
|
| 358 |
+
...props
|
| 359 |
+
}: React.ComponentProps<typeof Separator>) {
|
| 360 |
+
return (
|
| 361 |
+
<Separator
|
| 362 |
+
data-slot="sidebar-separator"
|
| 363 |
+
data-sidebar="separator"
|
| 364 |
+
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
| 365 |
+
{...props}
|
| 366 |
+
/>
|
| 367 |
+
)
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 371 |
+
return (
|
| 372 |
+
<div
|
| 373 |
+
data-slot="sidebar-content"
|
| 374 |
+
data-sidebar="content"
|
| 375 |
+
className={cn(
|
| 376 |
+
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
|
| 377 |
+
className
|
| 378 |
+
)}
|
| 379 |
+
{...props}
|
| 380 |
+
/>
|
| 381 |
+
)
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 385 |
+
return (
|
| 386 |
+
<div
|
| 387 |
+
data-slot="sidebar-group"
|
| 388 |
+
data-sidebar="group"
|
| 389 |
+
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
| 390 |
+
{...props}
|
| 391 |
+
/>
|
| 392 |
+
)
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
function SidebarGroupLabel({
|
| 396 |
+
className,
|
| 397 |
+
asChild = false,
|
| 398 |
+
...props
|
| 399 |
+
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
| 400 |
+
const Comp = asChild ? Slot.Root : "div"
|
| 401 |
+
|
| 402 |
+
return (
|
| 403 |
+
<Comp
|
| 404 |
+
data-slot="sidebar-group-label"
|
| 405 |
+
data-sidebar="group-label"
|
| 406 |
+
className={cn(
|
| 407 |
+
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
| 408 |
+
className
|
| 409 |
+
)}
|
| 410 |
+
{...props}
|
| 411 |
+
/>
|
| 412 |
+
)
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function SidebarGroupAction({
|
| 416 |
+
className,
|
| 417 |
+
asChild = false,
|
| 418 |
+
...props
|
| 419 |
+
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
| 420 |
+
const Comp = asChild ? Slot.Root : "button"
|
| 421 |
+
|
| 422 |
+
return (
|
| 423 |
+
<Comp
|
| 424 |
+
data-slot="sidebar-group-action"
|
| 425 |
+
data-sidebar="group-action"
|
| 426 |
+
className={cn(
|
| 427 |
+
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
| 428 |
+
className
|
| 429 |
+
)}
|
| 430 |
+
{...props}
|
| 431 |
+
/>
|
| 432 |
+
)
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function SidebarGroupContent({
|
| 436 |
+
className,
|
| 437 |
+
...props
|
| 438 |
+
}: React.ComponentProps<"div">) {
|
| 439 |
+
return (
|
| 440 |
+
<div
|
| 441 |
+
data-slot="sidebar-group-content"
|
| 442 |
+
data-sidebar="group-content"
|
| 443 |
+
className={cn("w-full text-sm", className)}
|
| 444 |
+
{...props}
|
| 445 |
+
/>
|
| 446 |
+
)
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
| 450 |
+
return (
|
| 451 |
+
<ul
|
| 452 |
+
data-slot="sidebar-menu"
|
| 453 |
+
data-sidebar="menu"
|
| 454 |
+
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
| 455 |
+
{...props}
|
| 456 |
+
/>
|
| 457 |
+
)
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
| 461 |
+
return (
|
| 462 |
+
<li
|
| 463 |
+
data-slot="sidebar-menu-item"
|
| 464 |
+
data-sidebar="menu-item"
|
| 465 |
+
className={cn("group/menu-item relative", className)}
|
| 466 |
+
{...props}
|
| 467 |
+
/>
|
| 468 |
+
)
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
const sidebarMenuButtonVariants = cva(
|
| 472 |
+
"ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground gap-2 rounded-lg px-3 py-2 text-left text-sm transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 data-active:font-medium peer/menu-button flex w-full items-center overflow-hidden outline-hidden group/menu-button disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
|
| 473 |
+
{
|
| 474 |
+
variants: {
|
| 475 |
+
variant: {
|
| 476 |
+
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
| 477 |
+
outline:
|
| 478 |
+
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
| 479 |
+
},
|
| 480 |
+
size: {
|
| 481 |
+
default: "h-9 text-sm",
|
| 482 |
+
sm: "h-8 text-xs",
|
| 483 |
+
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
|
| 484 |
+
},
|
| 485 |
+
},
|
| 486 |
+
defaultVariants: {
|
| 487 |
+
variant: "default",
|
| 488 |
+
size: "default",
|
| 489 |
+
},
|
| 490 |
+
}
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
function SidebarMenuButton({
|
| 494 |
+
asChild = false,
|
| 495 |
+
isActive = false,
|
| 496 |
+
variant = "default",
|
| 497 |
+
size = "default",
|
| 498 |
+
tooltip,
|
| 499 |
+
className,
|
| 500 |
+
...props
|
| 501 |
+
}: React.ComponentProps<"button"> & {
|
| 502 |
+
asChild?: boolean
|
| 503 |
+
isActive?: boolean
|
| 504 |
+
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
| 505 |
+
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
| 506 |
+
const Comp = asChild ? Slot.Root : "button"
|
| 507 |
+
const { isMobile, state } = useSidebar()
|
| 508 |
+
|
| 509 |
+
const button = (
|
| 510 |
+
<Comp
|
| 511 |
+
data-slot="sidebar-menu-button"
|
| 512 |
+
data-sidebar="menu-button"
|
| 513 |
+
data-size={size}
|
| 514 |
+
data-active={isActive}
|
| 515 |
+
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
| 516 |
+
{...props}
|
| 517 |
+
/>
|
| 518 |
+
)
|
| 519 |
+
|
| 520 |
+
if (!tooltip) {
|
| 521 |
+
return button
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
if (typeof tooltip === "string") {
|
| 525 |
+
tooltip = {
|
| 526 |
+
children: tooltip,
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
return (
|
| 531 |
+
<Tooltip>
|
| 532 |
+
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
| 533 |
+
<TooltipContent
|
| 534 |
+
side="right"
|
| 535 |
+
align="center"
|
| 536 |
+
hidden={state !== "collapsed" || isMobile}
|
| 537 |
+
{...tooltip}
|
| 538 |
+
/>
|
| 539 |
+
</Tooltip>
|
| 540 |
+
)
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
function SidebarMenuAction({
|
| 544 |
+
className,
|
| 545 |
+
asChild = false,
|
| 546 |
+
showOnHover = false,
|
| 547 |
+
...props
|
| 548 |
+
}: React.ComponentProps<"button"> & {
|
| 549 |
+
asChild?: boolean
|
| 550 |
+
showOnHover?: boolean
|
| 551 |
+
}) {
|
| 552 |
+
const Comp = asChild ? Slot.Root : "button"
|
| 553 |
+
|
| 554 |
+
return (
|
| 555 |
+
<Comp
|
| 556 |
+
data-slot="sidebar-menu-action"
|
| 557 |
+
data-sidebar="menu-action"
|
| 558 |
+
className={cn(
|
| 559 |
+
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-2 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
| 560 |
+
showOnHover &&
|
| 561 |
+
"peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 md:opacity-0",
|
| 562 |
+
className
|
| 563 |
+
)}
|
| 564 |
+
{...props}
|
| 565 |
+
/>
|
| 566 |
+
)
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
function SidebarMenuBadge({
|
| 570 |
+
className,
|
| 571 |
+
...props
|
| 572 |
+
}: React.ComponentProps<"div">) {
|
| 573 |
+
return (
|
| 574 |
+
<div
|
| 575 |
+
data-slot="sidebar-menu-badge"
|
| 576 |
+
data-sidebar="menu-badge"
|
| 577 |
+
className={cn(
|
| 578 |
+
"text-sidebar-foreground peer-hover/menu-button:text-sidebar-accent-foreground peer-data-active/menu-button:text-sidebar-accent-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1",
|
| 579 |
+
className
|
| 580 |
+
)}
|
| 581 |
+
{...props}
|
| 582 |
+
/>
|
| 583 |
+
)
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
function SidebarMenuSkeleton({
|
| 587 |
+
className,
|
| 588 |
+
showIcon = false,
|
| 589 |
+
...props
|
| 590 |
+
}: React.ComponentProps<"div"> & {
|
| 591 |
+
showIcon?: boolean
|
| 592 |
+
}) {
|
| 593 |
+
// Random width between 50 to 90%.
|
| 594 |
+
const [width] = React.useState(() => {
|
| 595 |
+
return `${Math.floor(Math.random() * 40) + 50}%`
|
| 596 |
+
})
|
| 597 |
+
|
| 598 |
+
return (
|
| 599 |
+
<div
|
| 600 |
+
data-slot="sidebar-menu-skeleton"
|
| 601 |
+
data-sidebar="menu-skeleton"
|
| 602 |
+
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
| 603 |
+
{...props}
|
| 604 |
+
>
|
| 605 |
+
{showIcon && (
|
| 606 |
+
<Skeleton
|
| 607 |
+
className="size-4 rounded-md"
|
| 608 |
+
data-sidebar="menu-skeleton-icon"
|
| 609 |
+
/>
|
| 610 |
+
)}
|
| 611 |
+
<Skeleton
|
| 612 |
+
className="h-4 max-w-(--skeleton-width) flex-1"
|
| 613 |
+
data-sidebar="menu-skeleton-text"
|
| 614 |
+
style={
|
| 615 |
+
{
|
| 616 |
+
"--skeleton-width": width,
|
| 617 |
+
} as React.CSSProperties
|
| 618 |
+
}
|
| 619 |
+
/>
|
| 620 |
+
</div>
|
| 621 |
+
)
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
| 625 |
+
return (
|
| 626 |
+
<ul
|
| 627 |
+
data-slot="sidebar-menu-sub"
|
| 628 |
+
data-sidebar="menu-sub"
|
| 629 |
+
className={cn(
|
| 630 |
+
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
| 631 |
+
className
|
| 632 |
+
)}
|
| 633 |
+
{...props}
|
| 634 |
+
/>
|
| 635 |
+
)
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
function SidebarMenuSubItem({
|
| 639 |
+
className,
|
| 640 |
+
...props
|
| 641 |
+
}: React.ComponentProps<"li">) {
|
| 642 |
+
return (
|
| 643 |
+
<li
|
| 644 |
+
data-slot="sidebar-menu-sub-item"
|
| 645 |
+
data-sidebar="menu-sub-item"
|
| 646 |
+
className={cn("group/menu-sub-item relative", className)}
|
| 647 |
+
{...props}
|
| 648 |
+
/>
|
| 649 |
+
)
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
function SidebarMenuSubButton({
|
| 653 |
+
asChild = false,
|
| 654 |
+
size = "md",
|
| 655 |
+
isActive = false,
|
| 656 |
+
className,
|
| 657 |
+
...props
|
| 658 |
+
}: React.ComponentProps<"a"> & {
|
| 659 |
+
asChild?: boolean
|
| 660 |
+
size?: "sm" | "md"
|
| 661 |
+
isActive?: boolean
|
| 662 |
+
}) {
|
| 663 |
+
const Comp = asChild ? Slot.Root : "a"
|
| 664 |
+
|
| 665 |
+
return (
|
| 666 |
+
<Comp
|
| 667 |
+
data-slot="sidebar-menu-sub-button"
|
| 668 |
+
data-sidebar="menu-sub-button"
|
| 669 |
+
data-size={size}
|
| 670 |
+
data-active={isActive}
|
| 671 |
+
className={cn(
|
| 672 |
+
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden group-data-[collapsible=icon]:hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
| 673 |
+
className
|
| 674 |
+
)}
|
| 675 |
+
{...props}
|
| 676 |
+
/>
|
| 677 |
+
)
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
export {
|
| 681 |
+
Sidebar,
|
| 682 |
+
SidebarContent,
|
| 683 |
+
SidebarFooter,
|
| 684 |
+
SidebarGroup,
|
| 685 |
+
SidebarGroupAction,
|
| 686 |
+
SidebarGroupContent,
|
| 687 |
+
SidebarGroupLabel,
|
| 688 |
+
SidebarHeader,
|
| 689 |
+
SidebarInput,
|
| 690 |
+
SidebarInset,
|
| 691 |
+
SidebarMenu,
|
| 692 |
+
SidebarMenuAction,
|
| 693 |
+
SidebarMenuBadge,
|
| 694 |
+
SidebarMenuButton,
|
| 695 |
+
SidebarMenuItem,
|
| 696 |
+
SidebarMenuSkeleton,
|
| 697 |
+
SidebarMenuSub,
|
| 698 |
+
SidebarMenuSubButton,
|
| 699 |
+
SidebarMenuSubItem,
|
| 700 |
+
SidebarProvider,
|
| 701 |
+
SidebarRail,
|
| 702 |
+
SidebarSeparator,
|
| 703 |
+
SidebarTrigger,
|
| 704 |
+
useSidebar,
|
| 705 |
+
}
|
web/src/components/ui/skeleton.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
import { cn } from "@/lib/utils"
|
| 2 |
-
|
| 3 |
-
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
| 4 |
-
return (
|
| 5 |
-
<div
|
| 6 |
-
data-slot="skeleton"
|
| 7 |
-
className={cn("bg-muted animate-pulse rounded-xl", className)}
|
| 8 |
-
{...props}
|
| 9 |
-
/>
|
| 10 |
-
)
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
export { Skeleton }
|
|
|
|
| 1 |
+
import { cn } from "@/lib/utils"
|
| 2 |
+
|
| 3 |
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
| 4 |
+
return (
|
| 5 |
+
<div
|
| 6 |
+
data-slot="skeleton"
|
| 7 |
+
className={cn("bg-muted animate-pulse rounded-xl", className)}
|
| 8 |
+
{...props}
|
| 9 |
+
/>
|
| 10 |
+
)
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export { Skeleton }
|
web/src/components/ui/slider.tsx
CHANGED
|
@@ -1,59 +1,59 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Slider as SliderPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Slider({
|
| 9 |
-
className,
|
| 10 |
-
defaultValue,
|
| 11 |
-
value,
|
| 12 |
-
min = 0,
|
| 13 |
-
max = 100,
|
| 14 |
-
...props
|
| 15 |
-
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
| 16 |
-
const _values = React.useMemo(
|
| 17 |
-
() =>
|
| 18 |
-
Array.isArray(value)
|
| 19 |
-
? value
|
| 20 |
-
: Array.isArray(defaultValue)
|
| 21 |
-
? defaultValue
|
| 22 |
-
: [min, max],
|
| 23 |
-
[value, defaultValue, min, max]
|
| 24 |
-
)
|
| 25 |
-
|
| 26 |
-
return (
|
| 27 |
-
<SliderPrimitive.Root
|
| 28 |
-
data-slot="slider"
|
| 29 |
-
defaultValue={defaultValue}
|
| 30 |
-
value={value}
|
| 31 |
-
min={min}
|
| 32 |
-
max={max}
|
| 33 |
-
className={cn(
|
| 34 |
-
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
|
| 35 |
-
className
|
| 36 |
-
)}
|
| 37 |
-
{...props}
|
| 38 |
-
>
|
| 39 |
-
<SliderPrimitive.Track
|
| 40 |
-
data-slot="slider-track"
|
| 41 |
-
className="bg-muted relative grow overflow-hidden rounded-4xl data-horizontal:h-3 data-horizontal:w-full data-vertical:h-full data-vertical:w-3"
|
| 42 |
-
>
|
| 43 |
-
<SliderPrimitive.Range
|
| 44 |
-
data-slot="slider-range"
|
| 45 |
-
className="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
| 46 |
-
/>
|
| 47 |
-
</SliderPrimitive.Track>
|
| 48 |
-
{Array.from({ length: _values.length }, (_, index) => (
|
| 49 |
-
<SliderPrimitive.Thumb
|
| 50 |
-
data-slot="slider-thumb"
|
| 51 |
-
key={index}
|
| 52 |
-
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-4xl border bg-white shadow-sm transition-colors select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
| 53 |
-
/>
|
| 54 |
-
))}
|
| 55 |
-
</SliderPrimitive.Root>
|
| 56 |
-
)
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
export { Slider }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Slider as SliderPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Slider({
|
| 9 |
+
className,
|
| 10 |
+
defaultValue,
|
| 11 |
+
value,
|
| 12 |
+
min = 0,
|
| 13 |
+
max = 100,
|
| 14 |
+
...props
|
| 15 |
+
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
| 16 |
+
const _values = React.useMemo(
|
| 17 |
+
() =>
|
| 18 |
+
Array.isArray(value)
|
| 19 |
+
? value
|
| 20 |
+
: Array.isArray(defaultValue)
|
| 21 |
+
? defaultValue
|
| 22 |
+
: [min, max],
|
| 23 |
+
[value, defaultValue, min, max]
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<SliderPrimitive.Root
|
| 28 |
+
data-slot="slider"
|
| 29 |
+
defaultValue={defaultValue}
|
| 30 |
+
value={value}
|
| 31 |
+
min={min}
|
| 32 |
+
max={max}
|
| 33 |
+
className={cn(
|
| 34 |
+
"relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col",
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
{...props}
|
| 38 |
+
>
|
| 39 |
+
<SliderPrimitive.Track
|
| 40 |
+
data-slot="slider-track"
|
| 41 |
+
className="bg-muted relative grow overflow-hidden rounded-4xl data-horizontal:h-3 data-horizontal:w-full data-vertical:h-full data-vertical:w-3"
|
| 42 |
+
>
|
| 43 |
+
<SliderPrimitive.Range
|
| 44 |
+
data-slot="slider-range"
|
| 45 |
+
className="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
|
| 46 |
+
/>
|
| 47 |
+
</SliderPrimitive.Track>
|
| 48 |
+
{Array.from({ length: _values.length }, (_, index) => (
|
| 49 |
+
<SliderPrimitive.Thumb
|
| 50 |
+
data-slot="slider-thumb"
|
| 51 |
+
key={index}
|
| 52 |
+
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-4xl border bg-white shadow-sm transition-colors select-none hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
| 53 |
+
/>
|
| 54 |
+
))}
|
| 55 |
+
</SliderPrimitive.Root>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export { Slider }
|
web/src/components/ui/switch.tsx
CHANGED
|
@@ -1,33 +1,33 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Switch as SwitchPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function Switch({
|
| 9 |
-
className,
|
| 10 |
-
size = "default",
|
| 11 |
-
...props
|
| 12 |
-
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
| 13 |
-
size?: "sm" | "default"
|
| 14 |
-
}) {
|
| 15 |
-
return (
|
| 16 |
-
<SwitchPrimitive.Root
|
| 17 |
-
data-slot="switch"
|
| 18 |
-
data-size={size}
|
| 19 |
-
className={cn(
|
| 20 |
-
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-[3px] aria-invalid:ring-[3px] data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]",
|
| 21 |
-
className
|
| 22 |
-
)}
|
| 23 |
-
{...props}
|
| 24 |
-
>
|
| 25 |
-
<SwitchPrimitive.Thumb
|
| 26 |
-
data-slot="switch-thumb"
|
| 27 |
-
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0"
|
| 28 |
-
/>
|
| 29 |
-
</SwitchPrimitive.Root>
|
| 30 |
-
)
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export { Switch }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Switch as SwitchPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Switch({
|
| 9 |
+
className,
|
| 10 |
+
size = "default",
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
| 13 |
+
size?: "sm" | "default"
|
| 14 |
+
}) {
|
| 15 |
+
return (
|
| 16 |
+
<SwitchPrimitive.Root
|
| 17 |
+
data-slot="switch"
|
| 18 |
+
data-size={size}
|
| 19 |
+
className={cn(
|
| 20 |
+
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-[3px] aria-invalid:ring-[3px] data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]",
|
| 21 |
+
className
|
| 22 |
+
)}
|
| 23 |
+
{...props}
|
| 24 |
+
>
|
| 25 |
+
<SwitchPrimitive.Thumb
|
| 26 |
+
data-slot="switch-thumb"
|
| 27 |
+
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0"
|
| 28 |
+
/>
|
| 29 |
+
</SwitchPrimitive.Root>
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Switch }
|
web/src/components/ui/table.tsx
CHANGED
|
@@ -1,116 +1,116 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
|
| 5 |
-
import { cn } from "@/lib/utils"
|
| 6 |
-
|
| 7 |
-
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
| 8 |
-
return (
|
| 9 |
-
<div
|
| 10 |
-
data-slot="table-container"
|
| 11 |
-
className="relative w-full overflow-x-auto"
|
| 12 |
-
>
|
| 13 |
-
<table
|
| 14 |
-
data-slot="table"
|
| 15 |
-
className={cn("w-full caption-bottom text-sm", className)}
|
| 16 |
-
{...props}
|
| 17 |
-
/>
|
| 18 |
-
</div>
|
| 19 |
-
)
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
| 23 |
-
return (
|
| 24 |
-
<thead
|
| 25 |
-
data-slot="table-header"
|
| 26 |
-
className={cn("[&_tr]:border-b", className)}
|
| 27 |
-
{...props}
|
| 28 |
-
/>
|
| 29 |
-
)
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
| 33 |
-
return (
|
| 34 |
-
<tbody
|
| 35 |
-
data-slot="table-body"
|
| 36 |
-
className={cn("[&_tr:last-child]:border-0", className)}
|
| 37 |
-
{...props}
|
| 38 |
-
/>
|
| 39 |
-
)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
| 43 |
-
return (
|
| 44 |
-
<tfoot
|
| 45 |
-
data-slot="table-footer"
|
| 46 |
-
className={cn(
|
| 47 |
-
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
| 48 |
-
className
|
| 49 |
-
)}
|
| 50 |
-
{...props}
|
| 51 |
-
/>
|
| 52 |
-
)
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
| 56 |
-
return (
|
| 57 |
-
<tr
|
| 58 |
-
data-slot="table-row"
|
| 59 |
-
className={cn(
|
| 60 |
-
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
| 61 |
-
className
|
| 62 |
-
)}
|
| 63 |
-
{...props}
|
| 64 |
-
/>
|
| 65 |
-
)
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
| 69 |
-
return (
|
| 70 |
-
<th
|
| 71 |
-
data-slot="table-head"
|
| 72 |
-
className={cn(
|
| 73 |
-
"text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
| 74 |
-
className
|
| 75 |
-
)}
|
| 76 |
-
{...props}
|
| 77 |
-
/>
|
| 78 |
-
)
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
| 82 |
-
return (
|
| 83 |
-
<td
|
| 84 |
-
data-slot="table-cell"
|
| 85 |
-
className={cn(
|
| 86 |
-
"p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
| 87 |
-
className
|
| 88 |
-
)}
|
| 89 |
-
{...props}
|
| 90 |
-
/>
|
| 91 |
-
)
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
function TableCaption({
|
| 95 |
-
className,
|
| 96 |
-
...props
|
| 97 |
-
}: React.ComponentProps<"caption">) {
|
| 98 |
-
return (
|
| 99 |
-
<caption
|
| 100 |
-
data-slot="table-caption"
|
| 101 |
-
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
| 102 |
-
{...props}
|
| 103 |
-
/>
|
| 104 |
-
)
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
export {
|
| 108 |
-
Table,
|
| 109 |
-
TableHeader,
|
| 110 |
-
TableBody,
|
| 111 |
-
TableFooter,
|
| 112 |
-
TableHead,
|
| 113 |
-
TableRow,
|
| 114 |
-
TableCell,
|
| 115 |
-
TableCaption,
|
| 116 |
-
}
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
| 8 |
+
return (
|
| 9 |
+
<div
|
| 10 |
+
data-slot="table-container"
|
| 11 |
+
className="relative w-full overflow-x-auto"
|
| 12 |
+
>
|
| 13 |
+
<table
|
| 14 |
+
data-slot="table"
|
| 15 |
+
className={cn("w-full caption-bottom text-sm", className)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
</div>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
| 23 |
+
return (
|
| 24 |
+
<thead
|
| 25 |
+
data-slot="table-header"
|
| 26 |
+
className={cn("[&_tr]:border-b", className)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
| 33 |
+
return (
|
| 34 |
+
<tbody
|
| 35 |
+
data-slot="table-body"
|
| 36 |
+
className={cn("[&_tr:last-child]:border-0", className)}
|
| 37 |
+
{...props}
|
| 38 |
+
/>
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
| 43 |
+
return (
|
| 44 |
+
<tfoot
|
| 45 |
+
data-slot="table-footer"
|
| 46 |
+
className={cn(
|
| 47 |
+
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
| 48 |
+
className
|
| 49 |
+
)}
|
| 50 |
+
{...props}
|
| 51 |
+
/>
|
| 52 |
+
)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
| 56 |
+
return (
|
| 57 |
+
<tr
|
| 58 |
+
data-slot="table-row"
|
| 59 |
+
className={cn(
|
| 60 |
+
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
| 61 |
+
className
|
| 62 |
+
)}
|
| 63 |
+
{...props}
|
| 64 |
+
/>
|
| 65 |
+
)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
| 69 |
+
return (
|
| 70 |
+
<th
|
| 71 |
+
data-slot="table-head"
|
| 72 |
+
className={cn(
|
| 73 |
+
"text-foreground h-12 px-3 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
| 74 |
+
className
|
| 75 |
+
)}
|
| 76 |
+
{...props}
|
| 77 |
+
/>
|
| 78 |
+
)
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
| 82 |
+
return (
|
| 83 |
+
<td
|
| 84 |
+
data-slot="table-cell"
|
| 85 |
+
className={cn(
|
| 86 |
+
"p-3 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
| 87 |
+
className
|
| 88 |
+
)}
|
| 89 |
+
{...props}
|
| 90 |
+
/>
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function TableCaption({
|
| 95 |
+
className,
|
| 96 |
+
...props
|
| 97 |
+
}: React.ComponentProps<"caption">) {
|
| 98 |
+
return (
|
| 99 |
+
<caption
|
| 100 |
+
data-slot="table-caption"
|
| 101 |
+
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
| 102 |
+
{...props}
|
| 103 |
+
/>
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
export {
|
| 108 |
+
Table,
|
| 109 |
+
TableHeader,
|
| 110 |
+
TableBody,
|
| 111 |
+
TableFooter,
|
| 112 |
+
TableHead,
|
| 113 |
+
TableRow,
|
| 114 |
+
TableCell,
|
| 115 |
+
TableCaption,
|
| 116 |
+
}
|
web/src/components/ui/tabs.tsx
CHANGED
|
@@ -1,90 +1,90 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { cva, type VariantProps } from "class-variance-authority"
|
| 5 |
-
import { Tabs as TabsPrimitive } from "radix-ui"
|
| 6 |
-
|
| 7 |
-
import { cn } from "@/lib/utils"
|
| 8 |
-
|
| 9 |
-
function Tabs({
|
| 10 |
-
className,
|
| 11 |
-
orientation = "horizontal",
|
| 12 |
-
...props
|
| 13 |
-
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
| 14 |
-
return (
|
| 15 |
-
<TabsPrimitive.Root
|
| 16 |
-
data-slot="tabs"
|
| 17 |
-
data-orientation={orientation}
|
| 18 |
-
className={cn(
|
| 19 |
-
"group/tabs flex gap-2 data-horizontal:flex-col",
|
| 20 |
-
className
|
| 21 |
-
)}
|
| 22 |
-
{...props}
|
| 23 |
-
/>
|
| 24 |
-
)
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
const tabsListVariants = cva(
|
| 28 |
-
"rounded-4xl p-[3px] group-data-horizontal/tabs:h-9 group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
| 29 |
-
{
|
| 30 |
-
variants: {
|
| 31 |
-
variant: {
|
| 32 |
-
default: "bg-muted",
|
| 33 |
-
line: "gap-1 bg-transparent",
|
| 34 |
-
},
|
| 35 |
-
},
|
| 36 |
-
defaultVariants: {
|
| 37 |
-
variant: "default",
|
| 38 |
-
},
|
| 39 |
-
}
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
function TabsList({
|
| 43 |
-
className,
|
| 44 |
-
variant = "default",
|
| 45 |
-
...props
|
| 46 |
-
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
| 47 |
-
VariantProps<typeof tabsListVariants>) {
|
| 48 |
-
return (
|
| 49 |
-
<TabsPrimitive.List
|
| 50 |
-
data-slot="tabs-list"
|
| 51 |
-
data-variant={variant}
|
| 52 |
-
className={cn(tabsListVariants({ variant }), className)}
|
| 53 |
-
{...props}
|
| 54 |
-
/>
|
| 55 |
-
)
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
function TabsTrigger({
|
| 59 |
-
className,
|
| 60 |
-
...props
|
| 61 |
-
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
| 62 |
-
return (
|
| 63 |
-
<TabsPrimitive.Trigger
|
| 64 |
-
data-slot="tabs-trigger"
|
| 65 |
-
className={cn(
|
| 66 |
-
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 67 |
-
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
| 68 |
-
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
| 69 |
-
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
| 70 |
-
className
|
| 71 |
-
)}
|
| 72 |
-
{...props}
|
| 73 |
-
/>
|
| 74 |
-
)
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
function TabsContent({
|
| 78 |
-
className,
|
| 79 |
-
...props
|
| 80 |
-
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
| 81 |
-
return (
|
| 82 |
-
<TabsPrimitive.Content
|
| 83 |
-
data-slot="tabs-content"
|
| 84 |
-
className={cn("flex-1 text-sm outline-none", className)}
|
| 85 |
-
{...props}
|
| 86 |
-
/>
|
| 87 |
-
)
|
| 88 |
-
}
|
| 89 |
-
|
| 90 |
-
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 5 |
+
import { Tabs as TabsPrimitive } from "radix-ui"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Tabs({
|
| 10 |
+
className,
|
| 11 |
+
orientation = "horizontal",
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
| 14 |
+
return (
|
| 15 |
+
<TabsPrimitive.Root
|
| 16 |
+
data-slot="tabs"
|
| 17 |
+
data-orientation={orientation}
|
| 18 |
+
className={cn(
|
| 19 |
+
"group/tabs flex gap-2 data-horizontal:flex-col",
|
| 20 |
+
className
|
| 21 |
+
)}
|
| 22 |
+
{...props}
|
| 23 |
+
/>
|
| 24 |
+
)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const tabsListVariants = cva(
|
| 28 |
+
"rounded-4xl p-[3px] group-data-horizontal/tabs:h-9 group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
| 29 |
+
{
|
| 30 |
+
variants: {
|
| 31 |
+
variant: {
|
| 32 |
+
default: "bg-muted",
|
| 33 |
+
line: "gap-1 bg-transparent",
|
| 34 |
+
},
|
| 35 |
+
},
|
| 36 |
+
defaultVariants: {
|
| 37 |
+
variant: "default",
|
| 38 |
+
},
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
function TabsList({
|
| 43 |
+
className,
|
| 44 |
+
variant = "default",
|
| 45 |
+
...props
|
| 46 |
+
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
| 47 |
+
VariantProps<typeof tabsListVariants>) {
|
| 48 |
+
return (
|
| 49 |
+
<TabsPrimitive.List
|
| 50 |
+
data-slot="tabs-list"
|
| 51 |
+
data-variant={variant}
|
| 52 |
+
className={cn(tabsListVariants({ variant }), className)}
|
| 53 |
+
{...props}
|
| 54 |
+
/>
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function TabsTrigger({
|
| 59 |
+
className,
|
| 60 |
+
...props
|
| 61 |
+
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
| 62 |
+
return (
|
| 63 |
+
<TabsPrimitive.Trigger
|
| 64 |
+
data-slot="tabs-trigger"
|
| 65 |
+
className={cn(
|
| 66 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 67 |
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
| 68 |
+
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
| 69 |
+
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
| 70 |
+
className
|
| 71 |
+
)}
|
| 72 |
+
{...props}
|
| 73 |
+
/>
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function TabsContent({
|
| 78 |
+
className,
|
| 79 |
+
...props
|
| 80 |
+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
| 81 |
+
return (
|
| 82 |
+
<TabsPrimitive.Content
|
| 83 |
+
data-slot="tabs-content"
|
| 84 |
+
className={cn("flex-1 text-sm outline-none", className)}
|
| 85 |
+
{...props}
|
| 86 |
+
/>
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
web/src/components/ui/tooltip.tsx
CHANGED
|
@@ -1,57 +1,57 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
|
| 3 |
-
import * as React from "react"
|
| 4 |
-
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
| 5 |
-
|
| 6 |
-
import { cn } from "@/lib/utils"
|
| 7 |
-
|
| 8 |
-
function TooltipProvider({
|
| 9 |
-
delayDuration = 0,
|
| 10 |
-
...props
|
| 11 |
-
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
| 12 |
-
return (
|
| 13 |
-
<TooltipPrimitive.Provider
|
| 14 |
-
data-slot="tooltip-provider"
|
| 15 |
-
delayDuration={delayDuration}
|
| 16 |
-
{...props}
|
| 17 |
-
/>
|
| 18 |
-
)
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
function Tooltip({
|
| 22 |
-
...props
|
| 23 |
-
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
| 24 |
-
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
function TooltipTrigger({
|
| 28 |
-
...props
|
| 29 |
-
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
| 30 |
-
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
function TooltipContent({
|
| 34 |
-
className,
|
| 35 |
-
sideOffset = 0,
|
| 36 |
-
children,
|
| 37 |
-
...props
|
| 38 |
-
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
| 39 |
-
return (
|
| 40 |
-
<TooltipPrimitive.Portal>
|
| 41 |
-
<TooltipPrimitive.Content
|
| 42 |
-
data-slot="tooltip-content"
|
| 43 |
-
sideOffset={sideOffset}
|
| 44 |
-
className={cn(
|
| 45 |
-
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-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 bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-2xl px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl",
|
| 46 |
-
className
|
| 47 |
-
)}
|
| 48 |
-
{...props}
|
| 49 |
-
>
|
| 50 |
-
{children}
|
| 51 |
-
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" />
|
| 52 |
-
</TooltipPrimitive.Content>
|
| 53 |
-
</TooltipPrimitive.Portal>
|
| 54 |
-
)
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function TooltipProvider({
|
| 9 |
+
delayDuration = 0,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
| 12 |
+
return (
|
| 13 |
+
<TooltipPrimitive.Provider
|
| 14 |
+
data-slot="tooltip-provider"
|
| 15 |
+
delayDuration={delayDuration}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function Tooltip({
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
| 24 |
+
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function TooltipTrigger({
|
| 28 |
+
...props
|
| 29 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
| 30 |
+
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function TooltipContent({
|
| 34 |
+
className,
|
| 35 |
+
sideOffset = 0,
|
| 36 |
+
children,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
| 39 |
+
return (
|
| 40 |
+
<TooltipPrimitive.Portal>
|
| 41 |
+
<TooltipPrimitive.Content
|
| 42 |
+
data-slot="tooltip-content"
|
| 43 |
+
sideOffset={sideOffset}
|
| 44 |
+
className={cn(
|
| 45 |
+
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-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 bg-foreground text-background z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-2xl px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
>
|
| 50 |
+
{children}
|
| 51 |
+
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" />
|
| 52 |
+
</TooltipPrimitive.Content>
|
| 53 |
+
</TooltipPrimitive.Portal>
|
| 54 |
+
)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
web/src/hooks/use-mobile.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
const MOBILE_BREAKPOINT = 768
|
| 4 |
+
|
| 5 |
+
export function useIsMobile() {
|
| 6 |
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
| 7 |
+
|
| 8 |
+
React.useEffect(() => {
|
| 9 |
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
| 10 |
+
const onChange = () => {
|
| 11 |
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
| 12 |
+
}
|
| 13 |
+
mql.addEventListener("change", onChange)
|
| 14 |
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
| 15 |
+
return () => mql.removeEventListener("change", onChange)
|
| 16 |
+
}, [])
|
| 17 |
+
|
| 18 |
+
return !!isMobile
|
| 19 |
+
}
|
web/src/lib/session-store.ts
CHANGED
|
@@ -1,93 +1,257 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Client-side session cache for uploaded audio + diarization results.
|
| 3 |
-
* Uses module-level Map for File/Blob URL (can't be serialized) and
|
| 4 |
-
* sessionStorage for JSON data (survives Next.js client-side navigation).
|
| 5 |
-
*
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
//
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side session cache for uploaded audio + diarization results.
|
| 3 |
+
* Uses module-level Map for File/Blob URL (can't be serialized) and
|
| 4 |
+
* sessionStorage for JSON data (survives Next.js client-side navigation).
|
| 5 |
+
* Also maintains a localStorage-backed recent sessions list.
|
| 6 |
+
*
|
| 7 |
+
* Only import in "use client" components β this module uses browser APIs.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
export type Segment = {
|
| 11 |
+
id: number
|
| 12 |
+
speaker: string // e.g. "SPEAKER_00"
|
| 13 |
+
start: number // seconds
|
| 14 |
+
end: number // seconds
|
| 15 |
+
text: string
|
| 16 |
+
emotion: string
|
| 17 |
+
valence: number
|
| 18 |
+
arousal: number
|
| 19 |
+
face_emotion?: string // MobileViT-XXS FER β present only for video inputs
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export type DiarizeResult = {
|
| 23 |
+
segments: Segment[]
|
| 24 |
+
duration: number
|
| 25 |
+
text: string
|
| 26 |
+
filename: string
|
| 27 |
+
diarization_method?: string
|
| 28 |
+
has_video?: boolean // true when FER ran on a video input
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type SessionEntry = {
|
| 32 |
+
id: string
|
| 33 |
+
data: DiarizeResult
|
| 34 |
+
audioUrl: string // blob: URL β valid for this browser session
|
| 35 |
+
filename: string
|
| 36 |
+
file?: File // Original file (only in-memory)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// In-memory store (lives as long as the page/tab is open)
|
| 40 |
+
const _store = new Map<string, SessionEntry>()
|
| 41 |
+
|
| 42 |
+
function _sessionKey(id: string) {
|
| 43 |
+
return `ser-session:${id}`
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// βββ Recent Sessions Registry βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
|
| 48 |
+
export type RecentSession = {
|
| 49 |
+
id: string
|
| 50 |
+
filename: string
|
| 51 |
+
duration: number // seconds
|
| 52 |
+
speakerCount: number
|
| 53 |
+
createdAt: number // Date.now()
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const RECENT_KEY = "ser-recent-sessions"
|
| 57 |
+
const MAX_RECENT = 20
|
| 58 |
+
|
| 59 |
+
export function listRecentSessions(): RecentSession[] {
|
| 60 |
+
try {
|
| 61 |
+
const raw = localStorage.getItem(RECENT_KEY)
|
| 62 |
+
return raw ? (JSON.parse(raw) as RecentSession[]) : []
|
| 63 |
+
} catch {
|
| 64 |
+
return []
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function _pushRecentSession(entry: RecentSession) {
|
| 69 |
+
try {
|
| 70 |
+
const existing = listRecentSessions().filter((s) => s.id !== entry.id)
|
| 71 |
+
const updated = [entry, ...existing].slice(0, MAX_RECENT)
|
| 72 |
+
localStorage.setItem(RECENT_KEY, JSON.stringify(updated))
|
| 73 |
+
} catch {
|
| 74 |
+
// ignore quota errors
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function removeRecentSession(id: string) {
|
| 79 |
+
try {
|
| 80 |
+
const updated = listRecentSessions().filter((s) => s.id !== id)
|
| 81 |
+
localStorage.setItem(RECENT_KEY, JSON.stringify(updated))
|
| 82 |
+
} catch {
|
| 83 |
+
// ignore
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// βββ Initialization & Demo Data βββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
const INIT_KEY = "ser-app-initialized"
|
| 90 |
+
|
| 91 |
+
const DEMO_DATA: DiarizeResult = {
|
| 92 |
+
filename: "Welcome Demo.mp4",
|
| 93 |
+
duration: 42.5,
|
| 94 |
+
text: "Welcome to Ethos Studio! This is a demo session to show you how emotional speech recognition works.",
|
| 95 |
+
segments: [
|
| 96 |
+
{ id: 1, speaker: "SPEAKER_01", start: 0, end: 5.2, text: "Welcome to the future of emotional intelligence.", emotion: "Trust", valence: 0.8, arousal: 0.3 },
|
| 97 |
+
{ id: 2, speaker: "SPEAKER_01", start: 6.5, end: 12.8, text: "With Voxtral, we can now transcribe and analyze the tone of every conversation.", emotion: "Inspiration", valence: 0.9, arousal: 0.6 },
|
| 98 |
+
{ id: 3, speaker: "SPEAKER_02", start: 14.1, end: 18.5, text: "That sounds incredibly powerful for sales and companionship applications.", emotion: "Interest", valence: 0.6, arousal: 0.4 },
|
| 99 |
+
{ id: 4, speaker: "SPEAKER_01", start: 20.2, end: 28.4, text: "Exactly. It's about understanding the 'how' behind the 'what'.", emotion: "Confidence", valence: 0.7, arousal: 0.2 },
|
| 100 |
+
]
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Call this on app start.
|
| 105 |
+
* 1. Discards localStorage if this is a fresh browser session (mimics server restart cleanup).
|
| 106 |
+
* 2. Prepopulates with demo if empty.
|
| 107 |
+
*/
|
| 108 |
+
export function ensureStoreInitialized() {
|
| 109 |
+
if (typeof window === "undefined") return
|
| 110 |
+
|
| 111 |
+
// 1. Detect fresh session
|
| 112 |
+
const isInitialized = sessionStorage.getItem(INIT_KEY)
|
| 113 |
+
if (!isInitialized) {
|
| 114 |
+
// Clear everything for a clean start
|
| 115 |
+
localStorage.removeItem(RECENT_KEY)
|
| 116 |
+
|
| 117 |
+
// Clear all session cache keys from sessionStorage (since localStorage doesn't store them)
|
| 118 |
+
// Actually we use _sessionKey(id) which is stored in sessionStorage
|
| 119 |
+
for (const key in sessionStorage) {
|
| 120 |
+
if (key.startsWith("ser-session:")) sessionStorage.removeItem(key)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
sessionStorage.setItem(INIT_KEY, "true")
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// 2. Prepopulate if empty
|
| 127 |
+
const current = listRecentSessions()
|
| 128 |
+
if (current.length === 0) {
|
| 129 |
+
const demoId = "welcome-demo"
|
| 130 |
+
|
| 131 |
+
// Store data in sessionStorage fallback so it works immediately
|
| 132 |
+
try {
|
| 133 |
+
sessionStorage.setItem(_sessionKey(demoId), JSON.stringify({
|
| 134 |
+
data: DEMO_DATA,
|
| 135 |
+
filename: DEMO_DATA.filename
|
| 136 |
+
}))
|
| 137 |
+
} catch { }
|
| 138 |
+
|
| 139 |
+
_pushRecentSession({
|
| 140 |
+
id: demoId,
|
| 141 |
+
filename: DEMO_DATA.filename,
|
| 142 |
+
duration: DEMO_DATA.duration,
|
| 143 |
+
speakerCount: 2,
|
| 144 |
+
createdAt: Date.now(),
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// βββ Session Store API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
|
| 151 |
+
/** Create an initial session with just the file. Returns ID. */
|
| 152 |
+
export function createPendingSession(file: File): string {
|
| 153 |
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
| 154 |
+
const audioUrl = URL.createObjectURL(file)
|
| 155 |
+
|
| 156 |
+
const emptyData: DiarizeResult = {
|
| 157 |
+
segments: [],
|
| 158 |
+
duration: 0,
|
| 159 |
+
text: "",
|
| 160 |
+
filename: file.name
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
_store.set(id, { id, data: emptyData, audioUrl, filename: file.name, file })
|
| 164 |
+
|
| 165 |
+
// Initial persist to sessionStorage
|
| 166 |
+
try {
|
| 167 |
+
sessionStorage.setItem(_sessionKey(id), JSON.stringify({ data: emptyData, filename: file.name }))
|
| 168 |
+
} catch { }
|
| 169 |
+
|
| 170 |
+
// Initial register in recents
|
| 171 |
+
_pushRecentSession({
|
| 172 |
+
id,
|
| 173 |
+
filename: file.name,
|
| 174 |
+
duration: 0,
|
| 175 |
+
createdAt: Date.now(),
|
| 176 |
+
speakerCount: 0,
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
return id
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/** Update an existing session with results. */
|
| 183 |
+
export function updateSession(id: string, data: DiarizeResult) {
|
| 184 |
+
const entry = _store.get(id)
|
| 185 |
+
if (!entry) return
|
| 186 |
+
|
| 187 |
+
const updatedEntry = { ...entry, data }
|
| 188 |
+
_store.set(id, updatedEntry)
|
| 189 |
+
|
| 190 |
+
// Persist to sessionStorage
|
| 191 |
+
try {
|
| 192 |
+
sessionStorage.setItem(_sessionKey(id), JSON.stringify({ data, filename: entry.filename }))
|
| 193 |
+
} catch { }
|
| 194 |
+
|
| 195 |
+
// Update recents
|
| 196 |
+
const speakers = new Set(data.segments.map((s) => s.speaker))
|
| 197 |
+
_pushRecentSession({
|
| 198 |
+
id,
|
| 199 |
+
filename: entry.filename,
|
| 200 |
+
duration: data.duration,
|
| 201 |
+
createdAt: Date.now(),
|
| 202 |
+
speakerCount: speakers.size,
|
| 203 |
+
})
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/** Store a new session; returns the session ID. */
|
| 207 |
+
export function createSession(file: File, data: DiarizeResult): string {
|
| 208 |
+
const id = createPendingSession(file)
|
| 209 |
+
updateSession(id, data)
|
| 210 |
+
return id
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/** Retrieve a session. Returns null if not found. */
|
| 214 |
+
export function getSession(id: string): SessionEntry | null {
|
| 215 |
+
if (!id) return null
|
| 216 |
+
|
| 217 |
+
// Special case: Built-in Welcome Demo
|
| 218 |
+
if (id === "welcome-demo") {
|
| 219 |
+
return {
|
| 220 |
+
id: "welcome-demo",
|
| 221 |
+
data: DEMO_DATA,
|
| 222 |
+
audioUrl: "https://www.w3schools.com/html/horse.mp3", // Built-in sample
|
| 223 |
+
filename: DEMO_DATA.filename,
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// 1. Try in-memory (audio URL still valid)
|
| 228 |
+
const entry = _store.get(id)
|
| 229 |
+
if (entry) return entry
|
| 230 |
+
|
| 231 |
+
// 2. Fall back to sessionStorage (no audio URL after page reload)
|
| 232 |
+
try {
|
| 233 |
+
const raw = sessionStorage.getItem(_sessionKey(id))
|
| 234 |
+
if (raw) {
|
| 235 |
+
const { data, filename } = JSON.parse(raw) as { data: DiarizeResult; filename: string }
|
| 236 |
+
return { id, data, audioUrl: "", filename }
|
| 237 |
+
}
|
| 238 |
+
} catch {
|
| 239 |
+
// ignore parse errors
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
return null
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/** Release resources for a session. */
|
| 246 |
+
export function clearSession(id: string) {
|
| 247 |
+
const entry = _store.get(id)
|
| 248 |
+
if (entry?.audioUrl) {
|
| 249 |
+
URL.revokeObjectURL(entry.audioUrl)
|
| 250 |
+
}
|
| 251 |
+
_store.delete(id)
|
| 252 |
+
try {
|
| 253 |
+
sessionStorage.removeItem(_sessionKey(id))
|
| 254 |
+
} catch {
|
| 255 |
+
// ignore
|
| 256 |
+
}
|
| 257 |
+
}
|